Skip to main content

gsm_core/platforms/webchat/
provider.rs

1use 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/// WebChat provider wiring with secrets backend integration.
17#[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}