gsm_core/platforms/webchat/
provider.rs1use std::sync::Arc;
2
3use crate::{
4 cards::Card,
5 http::RawRequest,
6 ingress::VerifiedEvent,
7 platforms::provider::{PlatformInit, PlatformProvider},
8};
9use anyhow::{Context as _, Result, anyhow, bail};
10use async_trait::async_trait;
11use greentic_types::TenantCtx;
12use secrets_core::{Scope, SecretUri, SecretsBackend};
13
14use super::config::{Config, OAuthProviderConfig, SigningKeys};
15
16#[derive(Clone)]
18pub struct WebChatProvider {
19 config: Config,
20 secrets: Arc<dyn SecretsBackend + Send + Sync + 'static>,
21 signing_scope: Option<Scope>,
22 #[allow(dead_code)]
23 init: Option<PlatformInit>,
24}
25
26#[derive(Clone, Debug, PartialEq, Eq)]
27pub struct RouteContext {
28 env: String,
29 tenant: String,
30 team: Option<String>,
31}
32
33impl RouteContext {
34 pub fn new(env: String, tenant: String, team: Option<String>) -> Self {
35 Self { env, tenant, team }
36 }
37
38 pub fn env(&self) -> &str {
39 &self.env
40 }
41 pub fn tenant(&self) -> &str {
42 &self.tenant
43 }
44
45 pub fn team(&self) -> Option<&str> {
46 self.team.as_deref()
47 }
48}
49
50impl WebChatProvider {
51 pub fn new(config: Config, secrets: Arc<dyn SecretsBackend + Send + Sync + 'static>) -> Self {
52 Self {
53 config,
54 secrets,
55 signing_scope: None,
56 init: None,
57 }
58 }
59
60 pub fn with_signing_scope(mut self, scope: Scope) -> Self {
61 self.signing_scope = Some(scope);
62 self
63 }
64
65 pub fn with_platform_init(mut self, init: PlatformInit) -> Self {
66 self.init = Some(init);
67 self
68 }
69
70 pub fn signing_scope(&self) -> Option<&Scope> {
71 self.signing_scope.as_ref()
72 }
73
74 pub fn secrets(&self) -> Arc<dyn SecretsBackend + Send + Sync + 'static> {
75 Arc::clone(&self.secrets)
76 }
77
78 pub fn config(&self) -> &Config {
79 &self.config
80 }
81
82 pub async fn signing_keys(&self) -> anyhow::Result<SigningKeys> {
83 let scope = self
84 .signing_scope
85 .clone()
86 .ok_or_else(|| anyhow!("signing scope not configured"))?;
87 let secret = self
88 .fetch_secret(scope, WEBCHAT_CATEGORY, JWT_SIGNING_KEY_NAME)
89 .await?
90 .ok_or_else(|| anyhow!("missing webchat/jwt_signing_key secret"))?;
91 Ok(SigningKeys { secret })
92 }
93
94 pub async fn direct_line_secret(&self, ctx: &TenantCtx) -> anyhow::Result<Option<String>> {
95 self.scoped_secret_with_fallback(ctx, WEBCHAT_CATEGORY, CHANNEL_TOKEN_NAME)
96 .await
97 }
98
99 pub async fn oauth_config(
100 &self,
101 ctx: &TenantCtx,
102 ) -> anyhow::Result<Option<OAuthProviderConfig>> {
103 let issuer = match self
104 .scoped_secret_with_fallback(ctx, WEBCHAT_OAUTH_CATEGORY, OAUTH_ISSUER_NAME)
105 .await?
106 {
107 Some(value) => value,
108 None => return Ok(None),
109 };
110 let client_id = match self
111 .scoped_secret_with_fallback(ctx, WEBCHAT_OAUTH_CATEGORY, OAUTH_CLIENT_ID_NAME)
112 .await?
113 {
114 Some(value) => value,
115 None => return Ok(None),
116 };
117 let redirect_base = match self
118 .scoped_secret_with_fallback(ctx, WEBCHAT_OAUTH_CATEGORY, OAUTH_REDIRECT_BASE_NAME)
119 .await?
120 {
121 Some(value) => value,
122 None => return Ok(None),
123 };
124
125 Ok(Some(OAuthProviderConfig {
126 issuer,
127 client_id,
128 redirect_base,
129 }))
130 }
131
132 pub async fn oauth_client_secret(&self, ctx: &TenantCtx) -> anyhow::Result<Option<String>> {
133 self.scoped_secret_with_fallback(ctx, WEBCHAT_OAUTH_CATEGORY, OAUTH_CLIENT_SECRET_NAME)
134 .await
135 }
136
137 async fn scoped_secret_with_fallback(
138 &self,
139 ctx: &TenantCtx,
140 category: &str,
141 name: &str,
142 ) -> anyhow::Result<Option<String>> {
143 if let Some(team) = ctx.team.as_ref() {
144 let scope = scope_from_ctx(ctx, Some(team.as_ref().to_string()))?;
145 if let Some(value) = self.fetch_secret(scope, category, name).await? {
146 return Ok(Some(value));
147 }
148 }
149 let scope = scope_from_ctx(ctx, None)?;
150 self.fetch_secret(scope, category, name).await
151 }
152
153 async fn fetch_secret(
154 &self,
155 scope: Scope,
156 category: &str,
157 name: &str,
158 ) -> anyhow::Result<Option<String>> {
159 let backend = Arc::clone(&self.secrets);
160 let category = category.to_string();
161 let name = name.to_string();
162 tokio::task::spawn_blocking(move || {
163 let uri = SecretUri::new(scope, category, name)?;
164 let secret = backend.get(&uri, None).map_err(|err| anyhow!(err))?;
165 if let Some(secret) = secret {
166 if secret.deleted {
167 return Ok(None);
168 }
169 if let Some(record) = secret.record() {
170 let value = String::from_utf8(record.value.clone())
171 .context("secret value is not valid UTF-8")?
172 .trim()
173 .to_string();
174 if value.is_empty() {
175 Ok(None)
176 } else {
177 Ok(Some(value))
178 }
179 } else {
180 Ok(None)
181 }
182 } else {
183 Ok(None)
184 }
185 })
186 .await
187 .map_err(|err| anyhow!("failed to join secrets task: {err}"))?
188 }
189}
190
191#[async_trait]
192impl PlatformProvider for WebChatProvider {
193 fn platform_id(&self) -> &'static str {
194 "webchat"
195 }
196
197 async fn health(&self) -> Result<()> {
198 Ok(())
199 }
200
201 async fn send_card(&self, _ctx: &TenantCtx, _to: &str, _card: &Card) -> Result<()> {
202 bail!("WebChat provider not implemented yet")
203 }
204
205 async fn verify_webhook(&self, _raw: &RawRequest) -> Result<VerifiedEvent> {
206 bail!("WebChat provider not implemented yet")
207 }
208}
209
210const WEBCHAT_CATEGORY: &str = "webchat";
211const JWT_SIGNING_KEY_NAME: &str = "jwt_signing_key";
212const CHANNEL_TOKEN_NAME: &str = "channel_token";
213const WEBCHAT_OAUTH_CATEGORY: &str = "webchat_oauth";
214const OAUTH_ISSUER_NAME: &str = "issuer";
215const OAUTH_CLIENT_ID_NAME: &str = "client_id";
216const OAUTH_REDIRECT_BASE_NAME: &str = "redirect_base";
217const OAUTH_CLIENT_SECRET_NAME: &str = "client_secret";
218
219fn scope_from_ctx(ctx: &TenantCtx, team: Option<String>) -> anyhow::Result<Scope> {
220 Scope::new(
221 ctx.env.as_ref().to_ascii_lowercase(),
222 ctx.tenant.as_ref().to_ascii_lowercase(),
223 team.map(|value| value.to_ascii_lowercase()),
224 )
225 .map_err(|err| anyhow!(err))
226}