From 5c57d53d7e42f6565d4cce2c736ce3aeb6c1a90e Mon Sep 17 00:00:00 2001 From: oppiliappan Date: Wed, 9 Sep 2026 15:04:24 +0100 Subject: [PATCH] tranquil-gate: delegated account creation gate A small axum service in front of Tranquil's delegation API. It verifies the caller's service auth, enforces the verified-email and delegate-count policy, then forwards to Tranquil with its own credentials. Configuration goes through Confique, with env vars taking precedence over an optional TOML file, and the signing key is published at /.well-known/did.json. --- Cargo.lock | 28 ++ crates/tranquil-gate/Cargo.toml | 31 ++ crates/tranquil-gate/README.md | 130 +++++++ crates/tranquil-gate/config.example.toml | 13 + crates/tranquil-gate/src/config.rs | 103 ++++++ crates/tranquil-gate/src/gate.rs | 200 +++++++++++ crates/tranquil-gate/src/lib.rs | 237 ++++++++++++ crates/tranquil-gate/src/main.rs | 50 +++ crates/tranquil-gate/src/telemetry.rs | 26 ++ crates/tranquil-gate/src/tests.rs | 437 +++++++++++++++++++++++ crates/tranquil-gate/tests/config.rs | 124 +++++++ 11 files changed, 1379 insertions(+) create mode 100644 crates/tranquil-gate/Cargo.toml create mode 100644 crates/tranquil-gate/README.md create mode 100644 crates/tranquil-gate/config.example.toml create mode 100644 crates/tranquil-gate/src/config.rs create mode 100644 crates/tranquil-gate/src/gate.rs create mode 100644 crates/tranquil-gate/src/lib.rs create mode 100644 crates/tranquil-gate/src/main.rs create mode 100644 crates/tranquil-gate/src/telemetry.rs create mode 100644 crates/tranquil-gate/src/tests.rs create mode 100644 crates/tranquil-gate/tests/config.rs diff --git a/Cargo.lock b/Cargo.lock index c421d9e6d..09153fca6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7464,6 +7464,7 @@ dependencies = [ "rustls-platform-verifier", "serde", "serde_json", + "serde_urlencoded", "sync_wrapper", "tokio", "tokio-rustls", @@ -9326,6 +9327,33 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "tranquil-gate" +version = "0.0.1" +dependencies = [ + "anyhow", + "axum", + "base64", + "bobbin-runtime", + "bs58", + "clap", + "confique", + "jacquard-axum", + "jacquard-common", + "jacquard-identity", + "k256", + "reqwest 0.13.1", + "serde", + "serde_json", + "tangled-axum", + "tempfile", + "tokio", + "tower", + "tracing", + "tracing-subscriber", + "wiremock", +] + [[package]] name = "triomphe" version = "0.1.16" diff --git a/crates/tranquil-gate/Cargo.toml b/crates/tranquil-gate/Cargo.toml new file mode 100644 index 000000000..62bea5751 --- /dev/null +++ b/crates/tranquil-gate/Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "tranquil-gate" +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true + +[dependencies] +anyhow.workspace = true +axum.workspace = true +base64.workspace = true +bobbin-runtime.workspace = true +clap.workspace = true +confique.workspace = true +bs58.workspace = true +jacquard-axum.workspace = true +jacquard-common.workspace = true +jacquard-identity.workspace = true +k256.workspace = true +reqwest = { workspace = true, features = ["query"] } +serde.workspace = true +serde_json.workspace = true +tangled-axum.workspace = true +tokio.workspace = true +tracing.workspace = true +tracing-subscriber.workspace = true + +[dev-dependencies] +tempfile.workspace = true +wiremock.workspace = true +tower.workspace = true diff --git a/crates/tranquil-gate/README.md b/crates/tranquil-gate/README.md new file mode 100644 index 000000000..e13b87c54 --- /dev/null +++ b/crates/tranquil-gate/README.md @@ -0,0 +1,130 @@ +# Tranquil gate + +A Rust sidecar that creates passwordless Tranquil delegated accounts using the +requester's service auth. The requester is the sole initial controller with +Tranquil's owner scope preset. No provisioning account or password is needed. + +`POST /xrpc/sh.tangled.delegation.createAccount` accepts: + +```json +{"handle":"org.example","listServiceAuth":""} +``` + +Obtain two fresh tokens from the user's PDS using +`com.atproto.server.getServiceAuth`. Both must target the **Tranquil PDS DID**: + +- Bearer header: `lxm=farm.tranquil.delegation.createAccount`. +- `listServiceAuth`: `lxm=farm.tranquil.delegation.listControlledAccounts`. + +The gate validates both signatures, audience, expiry, method, nonce, and matching +issuers. It forwards each token unchanged to its corresponding Tranquil endpoint. +Tokens must have a `jti` and a lifetime of at most five minutes, with issuance no +more than 30 seconds in the future. Replays are rejected. Jacquard signature +verification and Tangled’s shared claims policy cover signature, expiry, audience, +and replay checks; the maximum lifetime and future issuance checks are additional +gate policy. Creation and listing share the same lifetime validation. +The response is `{"did":"did:plc:…","handle":"org.example","controllerDid":"did:plc:…"}`. +The caller cannot supply an email, controller DID, or controller scopes. + +The sidecar calls Deliberi's `sh.tangled.identity.resolveCommitters` with its own +signed service-auth token, as Knotmirror does. A DID-only response does not count +as a verified email. Resolver errors fail closed. Email addresses are not stored +on the new account. + +Two or more currently controlled accounts deny creation. Requests are serialized +and creation continues if the HTTP caller disconnects. Run **one sidecar instance +per PDS**; the lock and replay cache are not shared between replicas. Tranquil +remains the source of truth across restarts. An upstream timeout can have an +ambiguous outcome: inspect controlled accounts before retrying. The quota counts +current grants, not lifetime creations or email addresses. + +## Tranquil fork + +Compose builds the sibling `../tranquil-pds` fork directly, using +`localinfra/tranquil.Dockerfile`; no patch is applied. Reusable middleware authenticates creation, listing, and OAuth binding. +The HTTP routes are `/xrpc/_delegation.createDelegatedAccount` and +`/xrpc/_delegation.listControlledAccounts`. Service-auth method bindings remain +`farm.tranquil.delegation.createAccount` and +`farm.tranquil.delegation.listControlledAccounts`; these are not HTTP routes. +The local lexicons under `lexicons/farm/tranquil/delegation` describe the payloads. Request and +response shapes at Tranquil remain unchanged. The controller is always the JWT +issuer, never a caller-supplied DID or JWT subject. Existing user authentication +continues to work. + +Tranquil uses its existing registration setting: + +- `INVITE_CODE_REQUIRED=false`: remote users may create delegated accounts. +- `INVITE_CODE_REQUIRED=true`: creation requires an existing active account on + this same Tranquil PDS. No additional invite is needed. + +Delegated accounts cannot create further delegated accounts. The gate's email and +two-account policy applies to requests through the gate; Tranquil also accepts +direct authenticated requests under its registration policy. + +The local Dockerfile enables reqwest native CA roots alongside public web PKI roots, so the +Compose CA mount is trusted during cross-PDS OAuth. +Production Tranquil images need equivalent delegation and service-auth support. + +## Configuration + +Configuration uses Confique. Precedence is environment variables, an explicit +TOML file (`--config FILE` or `GATE_CONFIG`), `/etc/tranquil-gate/config.toml`, +then defaults. An explicitly selected file must exist; the system file is optional. +Existing environment-only deployments continue to work. + +See [config.example.toml](config.example.toml). TOML keys match the variable names +below without `GATE_`, in lowercase (for example, `tranquil_url`). Logging uses +`[log]` with `format` (`text` or `json`) and `filter` (tracing directives). +`GATE_LOG_FORMAT` overrides `log.format`; `RUST_LOG` overrides `log.filter`. +Logging defaults to `info`, with Unix microsecond timestamps as in Bobbin. + +```sh +tranquil-gate --config config.toml --check-config +tranquil-gate --config config.toml +``` + +`--check-config` validates configuration and client setup without starting a server. +It does not contact upstream services or print the signing key. + + +| Variable | Purpose | +| --- | --- | +| `GATE_DID` | Sidecar's public `did:web` identity for Deliberi requests | +| `GATE_SIGNING_KEY` | Base64-encoded 32-byte secp256k1 private key | +| `GATE_TRANQUIL_URL` | Tranquil base URL | +| `GATE_TRANQUIL_DID` | Tranquil's service-auth audience | +| `GATE_DELIBERI_URL` | Deliberi base URL | +| `GATE_DELIBERI_DID` | Deliberi's service-auth audience | +| `GATE_PLC_URL` | PLC directory base URL | + +Optional: `GATE_BIND` (default `0.0.0.0:3100`), `GATE_EXTRA_CA_FILE` (PEM), and +`GATE_LOG_FORMAT` (default `text`), and `RUST_LOG` (default `info`). The public key is published at `/.well-known/did.json`. Add `GATE_DID` +to Deliberi's `DELIBERI_TRUSTED_SERVICE_DIDS` and serve its DID document over HTTPS. +`GET /xrpc/_health` checks liveness. + +The web app uses `DELEGATION_URL` for the gate URL and `DELEGATION_DID` for the +**Tranquil PDS DID**. Its OAuth scope includes creation, listing, and delegation authorization. +Existing sessions need to sign in again to grant these permissions. Organization +setup is headless for local and remote controllers: it obtains a third service +JWT (`farm.tranquil.delegation.authorize`) for Tranquil, binds the OAuth PAR request +through `/oauth/delegation/auth-token`, approves permitted scopes through the +consent JSON APIs, and exchanges the authorization code using PKCE/DPoP. +The binding endpoint checks the issuer's existing delegation grant. Retrying +incomplete setup reuses the newly created organization. + +## Local Compose + +Compose configures Deliberi trust and serves the gate at +`https://delegates.tngl.boltless.dev`. The bundled signing key is for development. + +```sh +podman compose build tranquil tranquil-gate +podman compose up -d tranquil init-accounts deliberi tranquil-gate caddy web +cargo test -p tranquil-gate +``` + +Recreate an older Caddy container to install the new network alias. The standard +seed process supplies verified email fixtures for Alice, Bob, Charlie, and David. +Obtain service auth from their regular PDS, targeting +`did:web:tranquil.tngl.boltless.dev`, and request a handle under +`tranquil.tngl.boltless.dev`. diff --git a/crates/tranquil-gate/config.example.toml b/crates/tranquil-gate/config.example.toml new file mode 100644 index 000000000..352b9e07a --- /dev/null +++ b/crates/tranquil-gate/config.example.toml @@ -0,0 +1,13 @@ +bind = "0.0.0.0:3100" +did = "did:web:gate.example" +# Supply the base64-encoded secp256k1 private key using GATE_SIGNING_KEY. +tranquil_url = "https://tranquil.example" +tranquil_did = "did:web:tranquil.example" +deliberi_url = "https://deliberi.example" +deliberi_did = "did:web:deliberi.example" +plc_url = "https://plc.directory" +# extra_ca_file = "/etc/tranquil-gate/ca.pem" + +[log] +format = "text" # or "json" +filter = "info,tranquil_gate=debug" diff --git a/crates/tranquil-gate/src/config.rs b/crates/tranquil-gate/src/config.rs new file mode 100644 index 000000000..7e019b6c2 --- /dev/null +++ b/crates/tranquil-gate/src/config.rs @@ -0,0 +1,103 @@ +use std::{ + net::SocketAddr, + path::{Path, PathBuf}, +}; + +use anyhow::{Context, ensure}; +use base64::{Engine, engine::general_purpose::STANDARD}; +use confique::Config as _; +use jacquard_common::types::string::Did; +use k256::ecdsa::SigningKey; + +#[derive(confique::Config)] +pub struct Config { + #[config(env = "GATE_BIND", default = "0.0.0.0:3100")] + pub bind: SocketAddr, + #[config(env = "GATE_DID")] + pub did: String, + #[config(env = "GATE_SIGNING_KEY")] + pub signing_key: String, + #[config(env = "GATE_TRANQUIL_URL")] + pub tranquil_url: String, + #[config(env = "GATE_TRANQUIL_DID")] + pub tranquil_did: String, + #[config(env = "GATE_DELIBERI_URL")] + pub deliberi_url: String, + #[config(env = "GATE_DELIBERI_DID")] + pub deliberi_did: String, + #[config(env = "GATE_PLC_URL")] + pub plc_url: String, + #[config(env = "GATE_EXTRA_CA_FILE")] + pub extra_ca_file: Option, + #[config(env = "GATE_REQUIRE_VERIFIED_EMAIL", default = true)] + pub require_verified_email: bool, + #[config(env = "GATE_MAX_DELEGATE_ACCOUNTS", default = 2)] + pub max_delegate_accounts: usize, + #[config(nested)] + pub log: LogConfig, +} + +#[derive(confique::Config)] +pub struct LogConfig { + #[config(env = "GATE_LOG_FORMAT", default = "text")] + pub format: String, + #[config(env = "RUST_LOG", default = "info")] + pub filter: String, +} + +impl Config { + pub fn load(path: Option<&Path>) -> anyhow::Result { + let mut builder = Self::builder().env(); + if let Some(path) = path { + builder = builder.preloaded(confique::File::new(path)?.required().load()?); + } + let config = builder + .file("/etc/tranquil-gate/config.toml") + .load() + .context("load gate configuration")?; + config.validate()?; + Ok(config) + } + + pub fn validate(&self) -> anyhow::Result<()> { + for (name, value) in [ + ("did", &self.did), + ("tranquil_did", &self.tranquil_did), + ("deliberi_did", &self.deliberi_did), + ] { + Did::new(value.as_str()).with_context(|| format!("invalid {name}"))?; + } + for (name, value) in [ + ("tranquil_url", &self.tranquil_url), + ("deliberi_url", &self.deliberi_url), + ("plc_url", &self.plc_url), + ] { + let url = reqwest::Url::parse(value).with_context(|| format!("invalid {name}"))?; + ensure!( + matches!(url.scheme(), "http" | "https") + && url.host_str().is_some() + && url.username().is_empty() + && url.password().is_none() + && url.query().is_none() + && url.fragment().is_none(), + "{name} must be an HTTP(S) base URL without credentials, query, or fragment" + ); + } + self.signing_key()?; + ensure!( + matches!(self.log.format.as_str(), "text" | "json"), + "log.format must be text or json" + ); + tracing_subscriber::EnvFilter::try_new(format!("info,{}", self.log.filter)) + .context("invalid log.filter")?; + Ok(()) + } + + pub(crate) fn signing_key(&self) -> anyhow::Result { + let bytes = STANDARD + .decode(&self.signing_key) + .context("signing_key must be base64")?; + SigningKey::from_slice(&bytes) + .context("signing_key must be a 32-byte secp256k1 private key") + } +} diff --git a/crates/tranquil-gate/src/gate.rs b/crates/tranquil-gate/src/gate.rs new file mode 100644 index 000000000..819b48064 --- /dev/null +++ b/crates/tranquil-gate/src/gate.rs @@ -0,0 +1,200 @@ +use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; +use k256::ecdsa::{Signature, SigningKey, signature::Signer}; +use serde::Deserialize; +use serde_json::json; +use std::time::{SystemTime, UNIX_EPOCH}; + +use crate::{Error, now}; +use axum::http::StatusCode; + +#[derive(Deserialize)] +struct Committers { + committers: Vec, +} + +#[derive(Deserialize)] +struct Accounts { + accounts: Vec, +} + +#[derive(Deserialize)] +struct AccountRef { + did: String, +} + +/// Registration policy gating delegated account creation on Tranquil. +/// +/// Build with [`Gate::builder`], then call [`Gate::check`] for each request. +pub struct Gate { + http: reqwest::Client, + gate_did: String, + signing_key: SigningKey, + deliberi_url: String, + deliberi_did: String, + require_verified_email: bool, + max_delegate_accounts: usize, +} + +pub struct GateBuilder { + http: reqwest::Client, + gate_did: String, + signing_key: SigningKey, + deliberi_url: String, + deliberi_did: String, + require_verified_email: bool, + max_delegate_accounts: usize, +} + +impl Gate { + pub fn builder( + http: reqwest::Client, + gate_did: impl Into, + signing_key: SigningKey, + deliberi_url: impl Into, + deliberi_did: impl Into, + ) -> GateBuilder { + GateBuilder { + http, + gate_did: gate_did.into(), + signing_key, + deliberi_url: deliberi_url.into(), + deliberi_did: deliberi_did.into(), + require_verified_email: true, + max_delegate_accounts: 2, + } + } + + pub async fn check( + &self, + did: &str, + list_service_auth: &str, + tranquil_url: &str, + ) -> Result<(), Error> { + if self.require_verified_email { + self.check_verified_email(did).await?; + } + self.check_delegate_limit(did, list_service_auth, tranquil_url) + .await?; + Ok(()) + } + + async fn check_verified_email(&self, did: &str) -> Result<(), Error> { + let verified: Committers = self + .http + .post(format!( + "{}/xrpc/sh.tangled.identity.resolveCommitters", + self.deliberi_url + )) + .bearer_auth(self.resolver_token()) + .json(&json!({"actor": did})) + .send() + .await? + .error_for_status()? + .json() + .await?; + if !verified.committers.iter().any(|value| is_email(value)) { + return Err(Error(StatusCode::FORBIDDEN, "VerifiedEmailRequired")); + } + Ok(()) + } + + async fn check_delegate_limit( + &self, + did: &str, + list_service_auth: &str, + tranquil_url: &str, + ) -> Result<(), Error> { + let accounts: Accounts = self + .http + .get(format!( + "{tranquil_url}/xrpc/_delegation.listControlledAccounts" + )) + .bearer_auth(list_service_auth) + .send() + .await? + .error_for_status()? + .json() + .await?; + let parsed = jacquard_common::service_auth::parse_jwt(list_service_auth) + .map_err(|_| Error(StatusCode::FORBIDDEN, "InvalidServiceAuth"))?; + if parsed.claims().iss.as_str() != did { + return Err(Error(StatusCode::FORBIDDEN, "InvalidServiceAuth")); + } + let unique: std::collections::HashSet<_> = + accounts.accounts.iter().map(|a| &a.did).collect(); + if unique.len() >= self.max_delegate_accounts { + return Err(Error(StatusCode::FORBIDDEN, "DelegateLimitReached")); + } + Ok(()) + } + + fn resolver_token(&self) -> String { + let timestamp = now(); + let claims = json!({"iss": self.gate_did, "aud": self.deliberi_did, "iat": timestamp, + "exp": timestamp + 60, "lxm": "sh.tangled.identity.resolveCommitters", + "jti": format!("{}", SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos())}); + let input = format!( + "{}.{}", + URL_SAFE_NO_PAD.encode(br#"{"alg":"ES256K","typ":"JWT"}"#), + URL_SAFE_NO_PAD.encode(serde_json::to_vec(&claims).unwrap()) + ); + let signature: Signature = self.signing_key.sign(input.as_bytes()); + format!("{input}.{}", URL_SAFE_NO_PAD.encode(signature.to_bytes())) + } +} + +impl GateBuilder { + pub fn require_verified_email(mut self, require: bool) -> Self { + self.require_verified_email = require; + self + } + + pub fn max_delegate_accounts(mut self, max: usize) -> Self { + self.max_delegate_accounts = max; + self + } + + pub fn build(self) -> Gate { + Gate { + http: self.http, + gate_did: self.gate_did, + signing_key: self.signing_key, + deliberi_url: self.deliberi_url, + deliberi_did: self.deliberi_did, + require_verified_email: self.require_verified_email, + max_delegate_accounts: self.max_delegate_accounts, + } + } +} + +fn is_email(value: &str) -> bool { + let value = value.trim(); + !value.starts_with("did:") + && value.len() <= 320 + && !value.chars().any(char::is_whitespace) + && !value.chars().any(char::is_control) + && value.split_once('@').is_some_and(|(local, domain)| { + !local.is_empty() && !domain.is_empty() && !domain.contains('@') + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn email_filter() { + for value in [ + "did:plc:abcdefghijklmnopqrstuvwx", + "", + "@host", + "user@", + "a@@b", + "a\0@b", + "a b@host", + ] { + assert!(!is_email(value)); + } + assert!(is_email("person@example.com")); + } +} diff --git a/crates/tranquil-gate/src/lib.rs b/crates/tranquil-gate/src/lib.rs new file mode 100644 index 000000000..42798d789 --- /dev/null +++ b/crates/tranquil-gate/src/lib.rs @@ -0,0 +1,237 @@ +pub mod config; +mod gate; + +use axum::{ + Json, Router, + extract::{DefaultBodyLimit, State}, + http::StatusCode, + response::{IntoResponse, Response}, + routing::{get, post}, +}; +use bobbin_runtime::{ReqwestHttp, UnixMicros}; +use gate::Gate; +use jacquard_axum::service_auth::{ServiceAuthConfig, ServiceAuthError}; +use jacquard_common::{deps::fluent_uri::Uri, service_auth::ServiceAuthClaims, types::string::Did}; +use jacquard_identity::{JacquardResolver, resolver::PlcSource}; +use k256::ecdsa::SigningKey; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; +use std::{ + sync::Arc, + time::{Duration, SystemTime, UNIX_EPOCH}, +}; +use tangled_axum::atproto::{AtprotoService, ClaimsPolicy, ExtractAnyServiceAuth, Verified}; +use tokio::sync::Mutex; + +const OWNER: &str = "atproto repo:* blob:*/* rpc:* identity:* account:*?action=manage transition:generic transition:chat.bsky transition:email"; +type Directory = JacquardResolver; + +struct RuntimeConfig { + did: String, + tranquil_url: String, + signing_key: SigningKey, +} + +#[derive(Clone)] +pub struct App { + config: Arc, + http: reqwest::Client, + auth: ServiceAuthConfig>, + gate: Arc, + // One instance per PDS. Hold through the entire upstream creation, including + // when the caller disconnects, so parallel requests cannot bypass the quota. + creation: Arc>, +} + +impl App { + /// Validate configuration and prepare shared clients, auth policy, and quota lock. + pub fn new(config: &config::Config) -> anyhow::Result { + config.validate()?; + let mut http = reqwest::Client::builder() + .connect_timeout(Duration::from_secs(5)) + .timeout(Duration::from_secs(30)) + .redirect(reqwest::redirect::Policy::none()); + if let Some(path) = &config.extra_ca_file { + http = + http.add_root_certificate(reqwest::Certificate::from_pem(&std::fs::read(path)?)?); + } + let http = http.build()?; + let plc = format!("{}/", config.plc_url.trim_end_matches('/')); + let directory = Arc::new( + JacquardResolver::new(ReqwestHttp::new(http.clone()), Default::default()) + .with_plc_source(PlcSource::PlcDirectory { + base: Uri::parse(plc.as_str())?.to_owned(), + }), + ); + let signing_key = config.signing_key()?; + let gate = Gate::builder( + http.clone(), + config.did.clone(), + signing_key.clone(), + config.deliberi_url.trim_end_matches('/'), + config.deliberi_did.clone(), + ) + .require_verified_email(config.require_verified_email) + .max_delegate_accounts(config.max_delegate_accounts) + .build(); + Ok(Self { + config: Arc::new(RuntimeConfig { + did: config.did.clone(), + tranquil_url: config.tranquil_url.trim_end_matches('/').into(), + signing_key, + }), + http, + auth: ServiceAuthConfig::new(Did::new_owned(config.tranquil_did.clone())?, directory), + gate: Arc::new(gate), + creation: Arc::new(Mutex::new(())), + }) + } +} + +impl AtprotoService for App { + type Resolver = Directory; + fn resolver(&self) -> &Directory { + self.auth.resolver() + } + async fn verify_claims( + &self, + claims: &ServiceAuthClaims, + ) -> Result { + if claims.lxm.as_ref().map(|v| v.as_str()) != Some("farm.tranquil.delegation.createAccount") + { + return Err(ServiceAuthError::MethodBindingRequired); + } + ClaimsPolicy::from(&self.auth) + .require_lxm(true) + .check(claims, UnixMicros::new(now() * 1_000_000)) + .await + } +} + +pub(crate) fn now() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock") + .as_secs() +} + +pub(crate) struct Error(StatusCode, &'static str); +impl IntoResponse for Error { + fn into_response(self) -> Response { + (self.0, Json(json!({"error": self.1}))).into_response() + } +} +pub(crate) fn upstream(e: impl std::fmt::Display) -> Error { + tracing::warn!(error = %e, "upstream request failed"); + Error(StatusCode::BAD_GATEWAY, "UpstreamUnavailable") +} + +impl From for Error { + fn from(error: reqwest::Error) -> Self { + if let Some(status) = error.status() + && status.is_client_error() + { + return Self(status, "UpstreamRejected"); + } + upstream(error) + } +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +struct CreateInput { + handle: String, + list_service_auth: String, +} +#[derive(Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +struct Account { + did: String, + handle: String, + #[serde(default)] + controller_did: String, +} + +impl App { + async fn create(&self, actor: &str, token: &str, input: CreateInput) -> Result { + let _guard = self.creation.lock().await; + let cfg = &self.config; + + self.gate + .check(actor, &input.list_service_auth, &cfg.tranquil_url) + .await?; + + let mut account: Account = self + .http + .post(format!( + "{}/xrpc/_delegation.createDelegatedAccount", + cfg.tranquil_url + )) + .bearer_auth(token) + .json(&json!({"handle": input.handle, "controllerScopes": OWNER})) + .send() + .await? + .error_for_status()? + .json() + .await?; + account.controller_did = actor.to_owned(); + tracing::info!(controller = actor, did = %account.did, "created delegated account"); + Ok(account) + } +} + +async fn create( + State(app): State, + ExtractAnyServiceAuth(claims, token): ExtractAnyServiceAuth, + Json(input): Json, +) -> Result, Error> { + if input.handle.is_empty() || input.handle.len() > 253 || input.handle.trim() != input.handle { + return Err(Error(StatusCode::BAD_REQUEST, "InvalidHandle")); + } + // list_service_auth is forwarded to Tranquil unchanged; Tranquil verifies + // its signature, audience, method, and expiry. + tokio::spawn(async move { app.create(claims.iss.as_str(), &token, input).await }) + .await + .map_err(upstream)? + .map(Json) +} + +async fn did_document(State(app): State) -> Json { + let did = &app.config.did; + let mut key = vec![0xe7, 0x01]; // secp256k1-pub multicodec + key.extend_from_slice( + app.config + .signing_key + .verifying_key() + .to_encoded_point(true) + .as_bytes(), + ); + Json(json!({ + "@context": ["https://www.w3.org/ns/did/v1"], + "id": did, + "verificationMethod": [ + { + "id": format!("{did}#atproto"), + "type": "Multikey", + "controller": did, + "publicKeyMultibase": format!("z{}", bs58::encode(key).into_string()) + } + ] + })) +} + +async fn health() -> Json { + Json(json!({"status": "ok"})) +} + +pub fn router(app: App) -> Router { + Router::new() + .route("/xrpc/_health", get(health)) + .route("/.well-known/did.json", get(did_document)) + .route("/xrpc/sh.tangled.delegation.createAccount", post(create)) + .layer(DefaultBodyLimit::max(4096)) + .with_state(app) +} + +#[cfg(test)] +mod tests; diff --git a/crates/tranquil-gate/src/main.rs b/crates/tranquil-gate/src/main.rs new file mode 100644 index 000000000..c9fab79a5 --- /dev/null +++ b/crates/tranquil-gate/src/main.rs @@ -0,0 +1,50 @@ +use std::path::PathBuf; + +use clap::Parser; +use tranquil_gate::{App, config::Config, router}; + +mod telemetry; + +#[derive(Parser)] +#[command(about = "Provision Tranquil delegated accounts for verified Tangled users")] +struct Cli { + #[arg(short, long, env = "GATE_CONFIG", value_name = "FILE")] + config: Option, + + #[arg(long)] + check_config: bool, +} + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + let cli = Cli::parse(); + let config = Config::load(cli.config.as_deref())?; + telemetry::init(&config.log)?; + let app = App::new(&config)?; + if cli.check_config { + println!("configuration is valid"); + return Ok(()); + } + let listener = tokio::net::TcpListener::bind(config.bind).await?; + tracing::info!(address = %listener.local_addr()?, "tranquil-gate listening"); + axum::serve(listener, router(app)) + .with_graceful_shutdown(shutdown_signal()) + .await?; + Ok(()) +} + +async fn shutdown_signal() { + #[cfg(unix)] + let terminate = async { + tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) + .expect("install SIGTERM handler") + .recv() + .await; + }; + #[cfg(not(unix))] + let terminate = std::future::pending::<()>(); + tokio::select! { + _ = tokio::signal::ctrl_c() => {}, + _ = terminate => {}, + } +} diff --git a/crates/tranquil-gate/src/telemetry.rs b/crates/tranquil-gate/src/telemetry.rs new file mode 100644 index 000000000..f8cf4352e --- /dev/null +++ b/crates/tranquil-gate/src/telemetry.rs @@ -0,0 +1,26 @@ +use std::sync::Arc; + +use bobbin_runtime::{Clock, SystemClock}; +use tracing_subscriber::{EnvFilter, filter::LevelFilter}; +use tranquil_gate::config::LogConfig; + +struct ClockTimer(Arc); + +impl tracing_subscriber::fmt::time::FormatTime for ClockTimer { + fn format_time(&self, w: &mut tracing_subscriber::fmt::format::Writer<'_>) -> std::fmt::Result { + write!(w, "{}", self.0.now_unix_micros().raw()) + } +} + +pub fn init(config: &LogConfig) -> anyhow::Result<()> { + let filter = EnvFilter::try_new(format!("{},{}", LevelFilter::INFO, config.filter))?; + let subscriber = tracing_subscriber::fmt() + .with_env_filter(filter) + .with_timer(ClockTimer(Arc::new(SystemClock::new()))); + let result = match config.format.as_str() { + "text" => subscriber.try_init(), + "json" => subscriber.json().try_init(), + _ => anyhow::bail!("log.format must be text or json"), + }; + result.map_err(|error| anyhow::anyhow!("initialize tracing: {error}")) +} diff --git a/crates/tranquil-gate/src/tests.rs b/crates/tranquil-gate/src/tests.rs new file mode 100644 index 000000000..88034d3f6 --- /dev/null +++ b/crates/tranquil-gate/src/tests.rs @@ -0,0 +1,437 @@ +use super::*; +use axum::{body::Body, http::Request}; +use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; +use k256::ecdsa::{Signature, signature::Signer}; +use tower::ServiceExt; +use wiremock::{ + Mock, MockServer, ResponseTemplate, + matchers::{body_json, header, method, path}, +}; + +const ACTOR: &str = "did:plc:abcdefghijklmnopqrstuvwx"; +const GATE: &str = "did:web:gate.example"; +const CREATE: &str = "farm.tranquil.delegation.createAccount"; +const LIST: &str = "farm.tranquil.delegation.listControlledAccounts"; +const RESOLVE: &str = "sh.tangled.identity.resolveCommitters"; + +async fn setup() -> (App, MockServer) { + let server = MockServer::start().await; + let http = reqwest::Client::new(); + let directory = Arc::new( + JacquardResolver::new(ReqwestHttp::new(http.clone()), Default::default()).with_plc_source( + PlcSource::PlcDirectory { + base: Uri::parse(format!("{}/", server.uri()).as_str()) + .unwrap() + .to_owned(), + }, + ), + ); + let signing_key = SigningKey::from_slice(&[1; 32]).unwrap(); + let gate = gate::Gate::builder( + http.clone(), + GATE, + signing_key.clone(), + server.uri(), + "did:web:deliberi.example", + ) + .build(); + let app = App { + config: Arc::new(RuntimeConfig { + did: GATE.into(), + tranquil_url: server.uri(), + signing_key, + }), + http, + auth: ServiceAuthConfig::new(Did::new_owned(GATE).unwrap(), directory), + gate: Arc::new(gate), + creation: Arc::new(Mutex::new(())), + }; + let mut key = vec![0xe7, 1]; + key.extend_from_slice( + app.config + .signing_key + .verifying_key() + .to_encoded_point(true) + .as_bytes(), + ); + Mock::given(method("GET")).and(path(format!("/{ACTOR}"))).respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "id": ACTOR, "verificationMethod": [{"id": format!("{ACTOR}#atproto"), "type":"Multikey", "controller": ACTOR, + "publicKeyMultibase": format!("z{}", bs58::encode(key).into_string())}] + }))).mount(&server).await; + (app, server) +} + +fn token(app: &App, aud: &str, lxm: &str, exp: u64, key: Option<&SigningKey>) -> String { + let payload = json!({"iss": ACTOR, "aud": aud, "lxm": lxm, "iat": now(), "exp": exp, "jti": format!("test-{lxm}")}); + let input = format!( + "{}.{}", + URL_SAFE_NO_PAD.encode(br#"{"alg":"ES256K","typ":"JWT"}"#), + URL_SAFE_NO_PAD.encode(serde_json::to_vec(&payload).unwrap()) + ); + let signature: Signature = key + .unwrap_or(&app.config.signing_key) + .sign(input.as_bytes()); + format!("{input}.{}", URL_SAFE_NO_PAD.encode(signature.to_bytes())) +} + +async fn request(app: App, token: Option<&str>) -> Response { + let list = token_for_list(&app); + request_with_list(app, token, &list).await +} + +async fn request_with_list(app: App, token: Option<&str>, list: &str) -> Response { + let mut request = Request::post("/xrpc/sh.tangled.delegation.createAccount") + .header("content-type", "application/json"); + if let Some(token) = token { + request = request.header("authorization", format!("Bearer {token}")); + } + router(app) + .oneshot( + request + .body(Body::from( + json!({"handle":"org.example", "listServiceAuth":list}).to_string(), + )) + .unwrap(), + ) + .await + .unwrap() +} + +async fn email(server: &MockServer, values: Vec<&str>) { + Mock::given(method("POST")) + .and(path(format!("/xrpc/{RESOLVE}"))) + .and(body_json(json!({"actor": ACTOR}))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({"committers": values}))) + .mount(server) + .await; +} + +fn token_for_list(app: &App) -> String { + token(app, GATE, LIST, now() + 60, None) +} + +#[tokio::test] +async fn rejects_missing_wrong_audience_expired_wrong_method_and_bad_signature() { + let (app, server) = setup().await; + let bad_key = SigningKey::from_slice(&[2; 32]).unwrap(); + let tokens = [ + None, + Some(token( + &app, + "did:web:other.example", + CREATE, + now() + 60, + None, + )), + Some(token(&app, GATE, CREATE, now() - 1, None)), + Some(token(&app, GATE, RESOLVE, now() + 60, None)), + Some(token(&app, GATE, CREATE, now() + 60, Some(&bad_key))), + ]; + for token in tokens { + assert!( + request(app.clone(), token.as_deref()) + .await + .status() + .is_client_error() + ); + } + assert!( + server + .received_requests() + .await + .unwrap() + .iter() + .all(|r| r.method == "GET") + ); +} + +#[tokio::test] +async fn requires_an_email_not_the_did_fallback_and_rejects_replay() { + let (app, server) = setup().await; + email(&server, vec![ACTOR]).await; + let jwt = token(&app, GATE, CREATE, now() + 60, None); + let response = request(app.clone(), Some(&jwt)).await; + assert_eq!(response.status(), StatusCode::FORBIDDEN); + let body = axum::body::to_bytes(response.into_body(), 4096) + .await + .unwrap(); + assert!( + std::str::from_utf8(&body) + .unwrap() + .contains("VerifiedEmailRequired") + ); + assert!(request(app, Some(&jwt)).await.status().is_client_error()); + assert_eq!( + server + .received_requests() + .await + .unwrap() + .iter() + .filter(|r| r.method == "POST") + .count(), + 1 + ); +} + +#[tokio::test] +async fn quota_counts_accounts_from_pds() { + let (app, server) = setup().await; + email(&server, vec![ACTOR, "person@example.com"]).await; + Mock::given(method("GET")).and(path("/xrpc/_delegation.listControlledAccounts")).and(header("authorization", format!("Bearer {}", token_for_list(&app)))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({"controllerDid": ACTOR, "accounts": [{"did":"did:plc:first"},{"did":"did:plc:second"}]}))).mount(&server).await; + let error = app + .create( + ACTOR, + "create-token", + CreateInput { + handle: "org.example".into(), + list_service_auth: token_for_list(&app), + }, + ) + .await + .err() + .unwrap(); + assert_eq!(error.1, "DelegateLimitReached"); +} + +#[tokio::test] +async fn upstream_client_errors_pass_through_and_server_errors_fail_closed() { + let (app, server) = setup().await; + assert_eq!( + app.create( + ACTOR, + "create-token", + CreateInput { + handle: "org.example".into(), + list_service_auth: token_for_list(&app), + } + ) + .await + .err() + .unwrap() + .0, + StatusCode::NOT_FOUND + ); + email(&server, vec!["person@example.com"]).await; + Mock::given(path("/xrpc/_delegation.listControlledAccounts")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({"accounts": []}))) + .mount(&server) + .await; + assert_eq!( + app.create( + ACTOR, + "create-token", + CreateInput { + handle: "org.example".into(), + list_service_auth: token_for_list(&app), + } + ) + .await + .err() + .unwrap() + .0, + StatusCode::NOT_FOUND + ); + Mock::given(path("/xrpc/_delegation.createDelegatedAccount")) + .respond_with(ResponseTemplate::new(503)) + .mount(&server) + .await; + assert_eq!( + app.create( + ACTOR, + "create-token", + CreateInput { + handle: "org.example".into(), + list_service_auth: token_for_list(&app), + } + ) + .await + .err() + .unwrap() + .0, + StatusCode::BAD_GATEWAY + ); +} + +#[tokio::test] +async fn concurrent_requests_create_at_most_two_with_the_requester_as_owner() { + use std::sync::atomic::{AtomicUsize, Ordering}; + let (app, server) = setup().await; + email(&server, vec!["person@example.com"]).await; + let count = Arc::new(AtomicUsize::new(0)); + let read_count = count.clone(); + Mock::given(method("GET")).and(path("/xrpc/_delegation.listControlledAccounts")) + .respond_with(move |_: &wiremock::Request| ResponseTemplate::new(200).set_body_json(json!({"controllerDid":ACTOR, + "accounts": (0..read_count.load(Ordering::SeqCst)).map(|i| json!({"did":format!("did:plc:{i}")})).collect::>() }))).mount(&server).await; + let write_count = count.clone(); + Mock::given(method("POST")).and(path("/xrpc/_delegation.createDelegatedAccount")) + .and(header("authorization", "Bearer create-token")) + .and(body_json(json!({"handle":"org.example", "controllerScopes":OWNER}))) + .respond_with(move |_: &wiremock::Request| { + let i = write_count.fetch_add(1, Ordering::SeqCst); + ResponseTemplate::new(200).set_delay(Duration::from_millis(20)) + .set_body_json(json!({"did": format!("did:plc:{i}"), "handle":"org.example", "controllerDid":ACTOR})) + }).mount(&server).await; + let mut tasks = Vec::new(); + for _ in 0..4 { + let app = app.clone(); + tasks.push(tokio::spawn(async move { + app.create( + ACTOR, + "create-token", + CreateInput { + handle: "org.example".into(), + list_service_auth: token_for_list(&app), + }, + ) + .await + })); + } + let mut successes = 0; + for task in tasks { + if task.await.unwrap().is_ok() { + successes += 1; + } + } + assert_eq!(successes, 2); + assert_eq!(count.load(Ordering::SeqCst), 2); + let requests = server.received_requests().await.unwrap(); + let resolver = requests + .iter() + .find(|r| r.url.path() == format!("/xrpc/{RESOLVE}")) + .unwrap(); + let raw = resolver + .headers + .get("authorization") + .unwrap() + .to_str() + .unwrap() + .strip_prefix("Bearer ") + .unwrap(); + let parsed = jacquard_common::service_auth::parse_jwt(raw).unwrap(); + let key = jacquard_common::service_auth::PublicKey::from_k256_bytes( + app.config + .signing_key + .verifying_key() + .to_encoded_point(true) + .as_bytes(), + ) + .unwrap(); + jacquard_common::service_auth::verify_signature(&parsed, &key).unwrap(); + assert_eq!(parsed.claims().iss.as_str(), GATE); + assert_eq!(parsed.claims().lxm.as_ref().unwrap().as_str(), RESOLVE); +} + +#[tokio::test] +async fn rejects_missing_nonce() { + let (app, _) = setup().await; + let claims: ServiceAuthClaims = serde_json::from_value(json!({ + "iss": ACTOR, "aud": GATE, "iat": now(), "exp": now() + 60, "lxm": CREATE, "jti": null + })) + .unwrap(); + assert!(app.verify_claims(&claims).await.is_err()); +} + +#[tokio::test] +async fn forwards_both_tokens_without_controller_overrides() { + let (app, server) = setup().await; + let create = token(&app, GATE, CREATE, now() + 60, None); + let list = token_for_list(&app); + email(&server, vec!["person@example.com"]).await; + Mock::given(method("GET")) + .and(path("/xrpc/_delegation.listControlledAccounts")) + .and(header("authorization", format!("Bearer {list}"))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({"accounts":[]}))) + .expect(1) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/xrpc/_delegation.createDelegatedAccount")) + .and(header("authorization", format!("Bearer {create}"))) + .and(body_json( + json!({"handle":"org.example", "controllerScopes":OWNER}), + )) + .respond_with( + ResponseTemplate::new(200) + .set_body_json(json!({"did":"did:plc:org", "handle":"org.example"})), + ) + .expect(1) + .mount(&server) + .await; + let response = request_with_list(app, Some(&create), &list).await; + assert_eq!(response.status(), StatusCode::OK); + let body = axum::body::to_bytes(response.into_body(), 4096) + .await + .unwrap(); + let account: Account = serde_json::from_slice(&body).unwrap(); + assert_eq!(account.controller_did, ACTOR); + assert!( + server + .received_requests() + .await + .unwrap() + .iter() + .all(|r| r.url.query().is_none() && !r.url.path().contains("createSession")) + ); +} + +#[tokio::test] +async fn passes_invalid_list_authorization_through_to_tranquil() { + // The gate no longer validates listServiceAuth itself; Tranquil rejects it + // and the gate relays Tranquil's status instead of masking it as a 502. + let (app, server) = setup().await; + email(&server, vec!["person@example.com"]).await; + Mock::given(method("GET")) + .and(path("/xrpc/_delegation.listControlledAccounts")) + .respond_with(ResponseTemplate::new(401).set_body_json(json!({"error": "AuthMissing"}))) + .mount(&server) + .await; + let create = token(&app, GATE, CREATE, now() + 60, None); + let response = request_with_list(app, Some(&create), "not-a-real-token").await; + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); +} + +#[tokio::test] +async fn rejects_another_controllers_valid_list_token_before_creation() { + let (app, server) = setup().await; + email(&server, vec!["person@example.com"]).await; + let other = "did:plc:zyxwvutsrqponmlkjihgfedc"; + let payload = json!({"iss": other, "aud": GATE, "lxm": LIST, + "iat": now(), "exp": now() + 60, "jti": "other-controller"}); + let input = format!( + "{}.{}", + URL_SAFE_NO_PAD.encode(br#"{"alg":"ES256K","typ":"JWT"}"#), + URL_SAFE_NO_PAD.encode(serde_json::to_vec(&payload).unwrap()) + ); + let signature: Signature = SigningKey::from_slice(&[2; 32]) + .unwrap() + .sign(input.as_bytes()); + let list = format!("{input}.{}", URL_SAFE_NO_PAD.encode(signature.to_bytes())); + // Tranquil accepts the other controller's authentic token and returns no orgs. + Mock::given(method("GET")) + .and(path("/xrpc/_delegation.listControlledAccounts")) + .and(header("authorization", format!("Bearer {list}"))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({"accounts": []}))) + .expect(1) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/xrpc/_delegation.createDelegatedAccount")) + .respond_with( + ResponseTemplate::new(200) + .set_body_json(json!({"did": "did:plc:org", "handle": "org.example"})), + ) + .expect(0) + .mount(&server) + .await; + let create = token(&app, GATE, CREATE, now() + 60, None); + let response = request_with_list(app, Some(&create), &list).await; + assert_eq!(response.status(), StatusCode::FORBIDDEN); + let body = axum::body::to_bytes(response.into_body(), 4096) + .await + .unwrap(); + assert_eq!( + serde_json::from_slice::(&body).unwrap()["error"], + "InvalidServiceAuth" + ); +} diff --git a/crates/tranquil-gate/tests/config.rs b/crates/tranquil-gate/tests/config.rs new file mode 100644 index 000000000..19e02c046 --- /dev/null +++ b/crates/tranquil-gate/tests/config.rs @@ -0,0 +1,124 @@ +use std::process::{Command, Output}; + +const CONFIG: &str = r#" +did = "did:web:gate.example" +signing_key = "AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=" +tranquil_url = "https://tranquil.example" +tranquil_did = "did:web:tranquil.example" +deliberi_url = "https://deliberi.example" +deliberi_did = "did:web:deliberi.example" +plc_url = "https://plc.directory" +[log] +format = "json" +filter = "tranquil_gate=debug" +"#; + +fn check(config: &str, env: &[(&str, &str)]) -> Output { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("config.toml"); + std::fs::write(&path, config).unwrap(); + Command::new(env!("CARGO_BIN_EXE_tranquil-gate")) + .env_clear() + .args(["--check-config", "--config"]) + .arg(path) + .envs(env.iter().copied()) + .output() + .unwrap() +} + +#[test] +fn toml_and_environment_overrides() { + let output = check(CONFIG, &[]); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + // An invalid file value must be replaced before validation. + assert!( + check( + &CONFIG.replace("https://tranquil.example", "invalid"), + &[("GATE_TRANQUIL_URL", "http://127.0.0.1:3000")] + ) + .status + .success() + ); + assert!( + check( + &CONFIG.replace("format = \"json\"", "format = \"invalid\""), + &[("GATE_LOG_FORMAT", "text")] + ) + .status + .success() + ); + assert!( + check( + &CONFIG.replace("tranquil_gate=debug", "[invalid"), + &[("RUST_LOG", "info")] + ) + .status + .success() + ); +} + +#[test] +fn invalid_configuration_fails_before_listening() { + for (name, value, message) in [ + ("GATE_BIND", "invalid", "bind"), + ("GATE_DID", "invalid", "did"), + ( + "GATE_TRANQUIL_URL", + "https://example.com?query", + "tranquil_url", + ), + ("GATE_SIGNING_KEY", "AAAA", "signing_key"), + ("GATE_LOG_FORMAT", "invalid", "log.format"), + ("RUST_LOG", "[invalid", "log.filter"), + ( + "GATE_EXTRA_CA_FILE", + "/nonexistent/gate-ca.pem", + "No such file", + ), + ] { + let output = check(CONFIG, &[(name, value)]); + assert!(!output.status.success(), "{name} accepted"); + let error = String::from_utf8_lossy(&output.stderr); + assert!(error.contains(message), "{name}: {error}"); + } +} + +#[test] +fn explicit_missing_file_is_an_error() { + let output = Command::new(env!("CARGO_BIN_EXE_tranquil-gate")) + .env_clear() + .env("GATE_CONFIG", "/nonexistent/gate-config.toml") + .arg("--check-config") + .output() + .unwrap(); + assert!(!output.status.success()); + assert!(String::from_utf8_lossy(&output.stderr).contains("gate-config.toml")); +} + +#[test] +fn existing_environment_only_configuration_works() { + let output = check( + "", + &[ + ("GATE_DID", "did:web:gate.example"), + ( + "GATE_SIGNING_KEY", + "AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=", + ), + ("GATE_TRANQUIL_URL", "https://tranquil.example"), + ("GATE_TRANQUIL_DID", "did:web:tranquil.example"), + ("GATE_DELIBERI_URL", "https://deliberi.example"), + ("GATE_DELIBERI_DID", "did:web:deliberi.example"), + ("GATE_PLC_URL", "https://plc.directory"), + ], + ); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); +} -- 2.51.2