use std::sync::Arc;
use std::sync::atomic::{AtomicU32, Ordering};
use wasmtime::component::ResourceTable;
use wasmtime_wasi::{WasiCtx, WasiCtxBuilder, WasiCtxView, WasiView};
use crate::host_bindings::greentic::extension_host::{broker, http, i18n, llm, logging, secrets};
use crate::host_ports::{KeyTranslator, LlmPort, SecretsBackend, Translator};
use crate::url_matcher::UrlMatcher;
use greentic_extension_sdk_contract::describe::Permissions;
pub const MAX_BROKER_DEPTH: u32 = 8;
pub struct HostState {
pub extension_id: String,
pub permissions: Permissions,
pub call_depth: AtomicU32,
translator: Arc<dyn Translator>,
secrets_backend: Arc<dyn SecretsBackend>,
http_client: Option<reqwest::blocking::Client>,
llm_port: Option<Arc<dyn LlmPort>>,
call_ctx: crate::host_ports::HostCallContext,
url_matcher: UrlMatcher,
runtime_weak: std::sync::Weak<crate::runtime::ExtensionRuntime>,
oauth_config: Option<crate::oauth::OAuthBrokerConfig>,
wasi: WasiCtx,
table: ResourceTable,
}
impl HostState {
#[must_use]
pub fn builder(extension_id: String, permissions: Permissions) -> HostStateBuilder {
HostStateBuilder {
extension_id,
permissions,
translator: Arc::new(KeyTranslator),
secrets_backend: Arc::new(crate::host_ports::InMemorySecrets::new()),
http_client: None,
llm_port: None,
call_ctx: crate::host_ports::HostCallContext::default(),
url_matcher: UrlMatcher::default(),
runtime_weak: std::sync::Weak::new(),
call_depth_start: 0,
oauth_config: None,
}
}
#[must_use]
pub fn translator(&self) -> &dyn Translator {
self.translator.as_ref()
}
#[must_use]
pub fn secrets_backend(&self) -> &dyn SecretsBackend {
self.secrets_backend.as_ref()
}
#[must_use]
pub fn url_matcher(&self) -> &UrlMatcher {
&self.url_matcher
}
}
pub struct HostStateBuilder {
extension_id: String,
permissions: Permissions,
translator: Arc<dyn Translator>,
secrets_backend: Arc<dyn SecretsBackend>,
http_client: Option<reqwest::blocking::Client>,
llm_port: Option<Arc<dyn LlmPort>>,
call_ctx: crate::host_ports::HostCallContext,
url_matcher: UrlMatcher,
runtime_weak: std::sync::Weak<crate::runtime::ExtensionRuntime>,
call_depth_start: u32,
oauth_config: Option<crate::oauth::OAuthBrokerConfig>,
}
impl HostStateBuilder {
#[must_use]
pub fn translator(mut self, t: Arc<dyn Translator>) -> Self {
self.translator = t;
self
}
#[must_use]
pub fn secrets_backend(mut self, s: Arc<dyn SecretsBackend>) -> Self {
self.secrets_backend = s;
self
}
#[must_use]
pub fn http_client(mut self, c: Option<reqwest::blocking::Client>) -> Self {
self.http_client = c;
self
}
#[must_use]
pub fn llm_port(mut self, p: Option<Arc<dyn LlmPort>>) -> Self {
self.llm_port = p;
self
}
#[must_use]
pub fn call_ctx(mut self, ctx: crate::host_ports::HostCallContext) -> Self {
self.call_ctx = ctx;
self
}
#[must_use]
pub fn url_matcher(mut self, m: UrlMatcher) -> Self {
self.url_matcher = m;
self
}
#[must_use]
pub fn runtime_weak(mut self, w: std::sync::Weak<crate::runtime::ExtensionRuntime>) -> Self {
self.runtime_weak = w;
self
}
#[must_use]
pub fn call_depth_start(mut self, n: u32) -> Self {
self.call_depth_start = n;
self
}
#[must_use]
pub fn oauth_config(mut self, c: Option<crate::oauth::OAuthBrokerConfig>) -> Self {
self.oauth_config = c;
self
}
#[must_use]
pub fn build(self) -> HostState {
let wasi = WasiCtxBuilder::new().build();
let table = ResourceTable::new();
HostState {
extension_id: self.extension_id,
permissions: self.permissions,
call_depth: AtomicU32::new(self.call_depth_start),
translator: self.translator,
secrets_backend: self.secrets_backend,
http_client: self.http_client,
llm_port: self.llm_port,
call_ctx: self.call_ctx,
url_matcher: self.url_matcher,
runtime_weak: self.runtime_weak,
oauth_config: self.oauth_config,
wasi,
table,
}
}
}
impl WasiView for HostState {
fn ctx(&mut self) -> WasiCtxView<'_> {
WasiCtxView {
ctx: &mut self.wasi,
table: &mut self.table,
}
}
}
impl logging::Host for HostState {
fn log(&mut self, level: logging::Level, target: String, message: String) {
let ext = &self.extension_id;
match level {
logging::Level::Trace => tracing::trace!(%ext, %target, "{message}"),
logging::Level::Debug => tracing::debug!(%ext, %target, "{message}"),
logging::Level::Info => tracing::info!(%ext, %target, "{message}"),
logging::Level::Warn => tracing::warn!(%ext, %target, "{message}"),
logging::Level::Error => tracing::error!(%ext, %target, "{message}"),
}
}
fn log_kv(
&mut self,
level: logging::Level,
target: String,
message: String,
fields: Vec<(String, String)>,
) {
let pairs: Vec<String> = fields.iter().map(|(k, v)| format!("{k}={v}")).collect();
let msg = if pairs.is_empty() {
message
} else {
format!("{message} {{{}}}", pairs.join(", "))
};
self.log(level, target, msg);
}
}
impl i18n::Host for HostState {
fn t(&mut self, key: String) -> String {
self.translator.t(&key)
}
fn tf(&mut self, key: String, args: Vec<(String, String)>) -> String {
let borrowed: Vec<(&str, &str)> =
args.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
self.translator.tf(&key, &borrowed)
}
}
impl secrets::Host for HostState {
fn get(&mut self, uri: String) -> Result<String, String> {
let permitted = self
.permissions
.secrets
.iter()
.any(|allowed| uri == *allowed || uri.starts_with(&format!("{allowed}/")));
if !permitted {
tracing::warn!(
ext = %self.extension_id,
requested = %uri,
"secrets::get permission denied"
);
return Err(format!("permission denied for secret: {uri}"));
}
match self.secrets_backend.get(&uri) {
Ok(value) => Ok(value),
Err(crate::host_ports::SecretsError::NotFound(k)) => {
Err(format!("secret not found: {k}"))
}
Err(crate::host_ports::SecretsError::Backend(msg)) => {
tracing::error!(ext = %self.extension_id, %msg, "secrets backend error");
Err(format!("secrets backend error: {msg}"))
}
}
}
}
impl broker::Host for HostState {
fn call_extension(
&mut self,
kind: String,
target_id: String,
function: String,
_args_json: String,
) -> Result<String, String> {
if !self
.permissions
.call_extension_kinds
.iter()
.any(|k| k == &kind)
{
tracing::warn!(
ext = %self.extension_id,
requested_kind = %kind,
"broker::call_extension permission denied"
);
return Err(format!(
"{} may not call {kind} extensions",
self.extension_id
));
}
let depth = self.call_depth.load(Ordering::Relaxed);
if depth >= MAX_BROKER_DEPTH {
tracing::warn!(
ext = %self.extension_id,
depth,
"broker::call_extension max depth exceeded"
);
return Err(format!(
"max broker call depth exceeded ({depth} >= {MAX_BROKER_DEPTH})"
));
}
let Some(_rt) = self.runtime_weak.upgrade() else {
return Err("broker: no runtime context available".into());
};
Err(format!(
"broker: dispatch to {target_id}.{function} pending (B.5 WASM round-trip)"
))
}
}
impl http::Host for HostState {
fn fetch(&mut self, req: http::Request) -> Result<http::Response, String> {
if !self.url_matcher.is_allowed(&req.url) {
tracing::warn!(
ext = %self.extension_id,
url = %req.url,
"http::fetch permission denied"
);
return Err(format!("network not allowed for url: {}", req.url));
}
let client = self
.http_client
.as_ref()
.ok_or_else(|| "http client not configured for this runtime".to_string())?;
let method = match req.method.to_uppercase().as_str() {
"GET" => reqwest::Method::GET,
"POST" => reqwest::Method::POST,
"PUT" => reqwest::Method::PUT,
"DELETE" => reqwest::Method::DELETE,
"PATCH" => reqwest::Method::PATCH,
"HEAD" => reqwest::Method::HEAD,
other => return Err(format!("unsupported http method: {other}")),
};
let mut builder = client.request(method, &req.url);
for (k, v) in &req.headers {
builder = builder.header(k.as_str(), v.as_str());
}
if let Some(body) = req.body {
builder = builder.body(body);
}
let resp = builder.send().map_err(|e| {
tracing::error!(ext = %self.extension_id, error = %e, "http::fetch transport error");
format!("http transport error: {e}")
})?;
let status = resp.status().as_u16();
let headers = resp
.headers()
.iter()
.filter_map(|(k, v)| {
v.to_str()
.ok()
.map(|s| (k.as_str().to_string(), s.to_string()))
})
.collect();
let body = resp
.bytes()
.map_err(|e| format!("http body error: {e}"))?
.to_vec();
Ok(http::Response {
status,
headers,
body,
})
}
}
impl llm::Host for HostState {
fn complete(&mut self, request: llm::LlmRequest) -> Result<llm::LlmResponse, String> {
let declared = &self.permissions.llm_roles;
let role = match (&request.role_hint, declared.as_slice()) {
(Some(hint), roles) if roles.iter().any(|r| r == hint) => hint.clone(),
(Some(hint), _) => {
tracing::warn!(ext = %self.extension_id, requested = %hint, "llm role not permitted");
return Err(format!("llm role not permitted: {hint}"));
}
(None, [sole]) => sole.clone(),
(None, []) => {
return Err("llm role not permitted: extension declares no llm_roles".to_string());
}
(None, _many) => {
return Err(
"llm role-hint required: extension declares multiple llm_roles".to_string(),
);
}
};
let Some(port) = self.llm_port.as_ref() else {
return Err("llm not configured for this runtime".to_string());
};
let port_req = crate::host_ports::LlmPortRequest {
system_prompt: request.system_prompt,
messages: request
.messages
.into_iter()
.map(|m| (m.role, m.content))
.collect(),
response_format: match request.response_format {
None | Some(llm::ResponseFormat::Text) => {
crate::host_ports::LlmPortResponseFormat::Text
}
Some(llm::ResponseFormat::Json) => crate::host_ports::LlmPortResponseFormat::Json,
Some(llm::ResponseFormat::JsonSchema(s)) => {
crate::host_ports::LlmPortResponseFormat::JsonSchema(s)
}
},
};
match port.complete(&self.extension_id, &self.call_ctx, &role, port_req) {
Ok(r) => Ok(llm::LlmResponse {
content: r.content,
total_tokens: r.total_tokens,
}),
Err(e) => {
tracing::warn!(ext = %self.extension_id, %role, error = %e, "llm port error");
Err(e.to_string())
}
}
}
}
impl crate::host_bindings::design_v04::greentic::oauth_broker::broker_v1::Host for HostState {
fn get_token(&mut self, provider_id: String, _subject: String, scopes: Vec<String>) -> String {
if !self
.permissions
.oauth_providers
.iter()
.any(|p| p == &provider_id)
{
tracing::warn!(
ext = %self.extension_id,
provider = %provider_id,
"oauth get-token permission denied"
);
return serde_json::json!({"error": "permission_denied", "provider": provider_id})
.to_string();
}
let Some(cfg) = self.oauth_config.clone() else {
return "{\"error\":\"oauth_broker_unconfigured\"}".to_string();
};
let Some(client) = self.http_client.clone() else {
return "{\"error\":\"oauth_broker_unconfigured\"}".to_string();
};
let req = crate::oauth::ResourceTokenRequest {
http_base_url: cfg.http_base_url,
env: cfg.env,
tenant: cfg.tenant,
team: cfg.team,
resource_id: provider_id.clone(),
scopes,
};
match crate::oauth::request_resource_token_blocking(
&client,
&req,
cfg.shared_secret.as_deref(),
) {
Ok(resp) => serde_json::to_string(&resp)
.unwrap_or_else(|_| "{\"error\":\"encode_failed\"}".to_string()),
Err(e) => {
tracing::warn!(provider = %provider_id, error = %e, "oauth get-token failed");
"{\"error\":\"broker_request_failed\"}".to_string()
}
}
}
fn get_consent_url(
&mut self,
_provider_id: String,
_subject: String,
_scopes: Vec<String>,
_redirect_path: String,
_extra_json: String,
) -> String {
String::new()
}
fn exchange_code(
&mut self,
_provider_id: String,
_subject: String,
_code: String,
_redirect_path: String,
) -> String {
String::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::host_bindings::greentic::extension_host::i18n::Host as I18nHost;
use crate::host_ports::Translator;
use std::sync::Arc;
struct EnglishToIndonesian;
impl Translator for EnglishToIndonesian {
fn t(&self, key: &str) -> String {
match key {
"greentic.test.hello" => "Halo dunia".to_string(),
_ => key.to_string(),
}
}
fn tf(&self, key: &str, args: &[(&str, &str)]) -> String {
let template = self.t(key);
args.iter()
.fold(template, |acc, (k, v)| acc.replace(&format!("{{{k}}}"), v))
}
}
fn host_with_translator(t: Arc<dyn Translator>) -> HostState {
HostState::builder("test-ext".to_string(), Permissions::default())
.translator(t)
.build()
}
#[test]
fn i18n_t_resolves_translated_value() {
let mut h = host_with_translator(Arc::new(EnglishToIndonesian));
let got = h.t("greentic.test.hello".to_string());
assert_eq!(got, "Halo dunia");
}
#[test]
fn i18n_t_falls_back_to_key_for_unknown() {
let mut h = host_with_translator(Arc::new(EnglishToIndonesian));
let got = h.t("missing.key".to_string());
assert_eq!(got, "missing.key");
}
#[test]
fn secrets_get_returns_value_when_permitted() {
use crate::host_bindings::greentic::extension_host::secrets::Host as SecretsHost;
use crate::host_ports::InMemorySecrets;
let mut backend = InMemorySecrets::default();
backend.insert("api.openai.com/api_key", "sk-real");
let mut perms = Permissions::default();
perms.secrets.push("api.openai.com/api_key".to_string());
let mut h = HostState::builder("test-ext".to_string(), perms)
.secrets_backend(Arc::new(backend))
.build();
let v = h.get("api.openai.com/api_key".to_string()).unwrap();
assert_eq!(v, "sk-real");
}
#[test]
fn secrets_get_denies_when_uri_not_in_permissions() {
use crate::host_bindings::greentic::extension_host::secrets::Host as SecretsHost;
use crate::host_ports::InMemorySecrets;
let mut backend = InMemorySecrets::default();
backend.insert("api.openai.com/api_key", "sk-real");
let perms = Permissions::default();
let mut h = HostState::builder("test-ext".to_string(), perms)
.secrets_backend(Arc::new(backend))
.build();
let err = h.get("api.openai.com/api_key".to_string()).unwrap_err();
assert!(
err.contains("permission denied"),
"expected permission denied, got: {err}"
);
}
#[test]
fn broker_denies_when_kind_not_in_permissions() {
use crate::host_bindings::greentic::extension_host::broker::Host as BrokerHost;
let mut perms = Permissions::default();
perms.call_extension_kinds.push("provider".to_string());
let mut h = HostState::builder("caller".into(), perms).build();
let err = h
.call_extension(
"design".into(),
"greentic.target".into(),
"do_something".into(),
"{}".into(),
)
.unwrap_err();
assert!(err.contains("may not call"), "got: {err}");
}
#[test]
fn broker_rejects_when_depth_exceeded() {
use crate::host_bindings::greentic::extension_host::broker::Host as BrokerHost;
let mut perms = Permissions::default();
perms.call_extension_kinds.push("design".to_string());
let mut h = HostState::builder("caller".into(), perms)
.call_depth_start(MAX_BROKER_DEPTH)
.build();
let err = h
.call_extension(
"design".into(),
"greentic.target".into(),
"do".into(),
"{}".into(),
)
.unwrap_err();
assert!(err.contains("max"), "expected depth error, got: {err}");
}
#[test]
fn http_fetch_denied_when_url_not_in_matcher() {
use crate::host_bindings::greentic::extension_host::http::{Host as HttpHost, Request};
let mut h = HostState::builder("test-ext".into(), Permissions::default())
.url_matcher(crate::url_matcher::UrlMatcher::from_patterns(vec![
"https://allowed.com/*".into(),
]))
.build();
let req = Request {
method: "GET".into(),
url: "https://evil.com/".into(),
headers: vec![],
body: None,
};
let err = h.fetch(req).unwrap_err();
assert!(
err.contains("not allowed") || err.contains("permission denied"),
"got: {err}"
);
}
#[test]
fn secrets_get_surfaces_backend_not_found() {
use crate::host_bindings::greentic::extension_host::secrets::Host as SecretsHost;
use crate::host_ports::InMemorySecrets;
let backend = InMemorySecrets::default();
let mut perms = Permissions::default();
perms.secrets.push("api.openai.com/api_key".to_string());
let mut h = HostState::builder("test-ext".to_string(), perms)
.secrets_backend(Arc::new(backend))
.build();
let err = h.get("api.openai.com/api_key".to_string()).unwrap_err();
assert!(err.contains("not found"), "got: {err}");
}
#[allow(clippy::struct_field_names)]
struct FakeLlm {
expected_extension_id: String,
expected_tenant: Option<String>,
expected_user_email: Option<String>,
expected_role: String,
}
impl crate::host_ports::LlmPort for FakeLlm {
fn complete(
&self,
extension_id: &str,
ctx: &crate::host_ports::HostCallContext,
role: &str,
request: crate::host_ports::LlmPortRequest,
) -> Result<crate::host_ports::LlmPortResponse, crate::host_ports::LlmPortError> {
assert_eq!(extension_id, self.expected_extension_id, "extension_id");
assert_eq!(ctx.tenant, self.expected_tenant, "tenant");
assert_eq!(ctx.user_email, self.expected_user_email, "user_email");
assert_eq!(role, self.expected_role, "role");
Ok(crate::host_ports::LlmPortResponse {
content: format!("echo:{}", request.system_prompt),
total_tokens: Some(7),
})
}
}
fn llm_request(role_hint: Option<&str>) -> llm::LlmRequest {
llm::LlmRequest {
role_hint: role_hint.map(str::to_string),
system_prompt: "you are a composer".to_string(),
messages: vec![],
response_format: None,
}
}
#[test]
fn llm_complete_resolves_sole_declared_role() {
use crate::host_bindings::greentic::extension_host::llm::Host as LlmHost;
let mut perms = Permissions::default();
perms.llm_roles.push("sorla_composer".to_string());
let port = Arc::new(FakeLlm {
expected_extension_id: "test-ext".to_string(),
expected_tenant: Some("acme".to_string()),
expected_user_email: None,
expected_role: "sorla_composer".to_string(),
});
let mut h = HostState::builder("test-ext".to_string(), perms)
.llm_port(Some(port))
.call_ctx(crate::host_ports::HostCallContext {
tenant: Some("acme".into()),
user_email: None,
})
.build();
let resp = h
.complete(llm_request(None))
.expect("complete should succeed");
assert_eq!(resp.content, "echo:you are a composer");
assert_eq!(resp.total_tokens, Some(7));
}
#[test]
fn llm_complete_passes_none_tenant_by_default() {
use crate::host_bindings::greentic::extension_host::llm::Host as LlmHost;
let mut perms = Permissions::default();
perms.llm_roles.push("sorla_composer".to_string());
let port = Arc::new(FakeLlm {
expected_extension_id: "test-ext".to_string(),
expected_tenant: None,
expected_user_email: None,
expected_role: "sorla_composer".to_string(),
});
let mut h = HostState::builder("test-ext".to_string(), perms)
.llm_port(Some(port))
.build();
let resp = h
.complete(llm_request(None))
.expect("complete should succeed");
assert_eq!(resp.content, "echo:you are a composer");
}
#[test]
fn llm_complete_passes_user_email() {
use crate::host_bindings::greentic::extension_host::llm::Host as LlmHost;
let mut perms = Permissions::default();
perms.llm_roles.push("sorla_composer".to_string());
let port = Arc::new(FakeLlm {
expected_extension_id: "test-ext".to_string(),
expected_tenant: Some("acme".to_string()),
expected_user_email: Some("alice@acme.com".to_string()),
expected_role: "sorla_composer".to_string(),
});
let mut h = HostState::builder("test-ext".to_string(), perms)
.llm_port(Some(port))
.call_ctx(crate::host_ports::HostCallContext {
tenant: Some("acme".into()),
user_email: Some("alice@acme.com".into()),
})
.build();
let resp = h
.complete(llm_request(None))
.expect("complete should succeed");
assert_eq!(resp.content, "echo:you are a composer");
}
#[test]
fn llm_complete_rejects_undeclared_role() {
use crate::host_bindings::greentic::extension_host::llm::Host as LlmHost;
let perms = Permissions::default(); let port = Arc::new(FakeLlm {
expected_extension_id: "test-ext".to_string(),
expected_tenant: None,
expected_user_email: None,
expected_role: "sorla_composer".to_string(),
});
let mut h = HostState::builder("test-ext".to_string(), perms)
.llm_port(Some(port))
.build();
let err = h.complete(llm_request(Some("sorla_composer"))).unwrap_err();
assert!(err.contains("llm role not permitted"), "got: {err}");
}
#[test]
fn llm_complete_without_port_errors() {
use crate::host_bindings::greentic::extension_host::llm::Host as LlmHost;
let mut perms = Permissions::default();
perms.llm_roles.push("sorla_composer".to_string());
let mut h = HostState::builder("test-ext".to_string(), perms).build();
let err = h.complete(llm_request(None)).unwrap_err();
assert!(err.contains("llm not configured"), "got: {err}");
}
#[test]
fn llm_complete_requires_hint_when_multiple_roles() {
use crate::host_bindings::greentic::extension_host::llm::Host as LlmHost;
let mut perms = Permissions::default();
perms.llm_roles.push("a".to_string());
perms.llm_roles.push("b".to_string());
let port = Arc::new(FakeLlm {
expected_extension_id: "test-ext".to_string(),
expected_tenant: None,
expected_user_email: None,
expected_role: "unused".to_string(),
});
let mut h = HostState::builder("test-ext".to_string(), perms)
.llm_port(Some(port))
.build();
let err = h.complete(llm_request(None)).unwrap_err();
assert!(err.contains("role-hint required"), "got: {err}");
}
#[test]
fn i18n_tf_substitutes_named_args() {
struct GreetTranslator;
impl Translator for GreetTranslator {
fn t(&self, key: &str) -> String {
if key == "greentic.test.greet" {
"Halo {name}!".to_string()
} else {
key.to_string()
}
}
fn tf(&self, key: &str, args: &[(&str, &str)]) -> String {
let template = self.t(key);
args.iter()
.fold(template, |acc, (k, v)| acc.replace(&format!("{{{k}}}"), v))
}
}
let mut h = host_with_translator(Arc::new(GreetTranslator));
let got = h.tf(
"greentic.test.greet".to_string(),
vec![("name".to_string(), "Bima".to_string())],
);
assert_eq!(got, "Halo Bima!");
}
#[test]
fn oauth_get_token_denied_when_provider_not_declared() {
use crate::host_bindings::design_v04::greentic::oauth_broker::broker_v1::Host as OAuthHost;
let mut h = HostState::builder("ext".into(), Permissions::default()).build();
let out = h.get_token("hubspot".into(), String::new(), vec![]);
assert!(out.contains("permission_denied"), "got: {out}");
}
#[test]
fn oauth_get_token_errors_when_unconfigured() {
use crate::host_bindings::design_v04::greentic::oauth_broker::broker_v1::Host as OAuthHost;
let mut perms = Permissions::default();
perms.oauth_providers.push("hubspot".into());
let mut h = HostState::builder("ext".into(), perms).build();
let out = h.get_token("hubspot".into(), String::new(), vec![]);
assert!(out.contains("oauth_broker_unconfigured"), "got: {out}");
}
#[test]
fn host_state_carries_oauth_config() {
use crate::oauth::OAuthBrokerConfig;
let cfg = OAuthBrokerConfig {
http_base_url: "https://oauth.example/".into(),
env: "dev".into(),
tenant: "acme".into(),
team: None,
shared_secret: Some("s".into()),
};
let h = HostState::builder("test-ext".to_string(), Permissions::default())
.oauth_config(Some(cfg.clone()))
.build();
assert_eq!(h.oauth_config.as_ref().unwrap().tenant, "acme");
}
}