Skip to main content

backbone_integrations/
lib.ext.rs

1//! The module's extension (hand-written; ADR-0031): what the module adds beside its
2//! generated services. The generated module and builder carry `ModuleExt` and
3//! `ModuleBuilderExt` and dereference to them, so their fields read as `module.field`.
4
5#[allow(unused_imports)]
6use super::*;
7
8/// State the module adds; read through the generated module's `Deref`.
9pub struct ModuleExt {
10    /// The hand-authored receive/retry/map path. The module's reason for existing — without this field
11    /// it's unreachable through the public API (CLAUDE.md: "MUST register every service in the {Domain}Module builder").
12    pub integrations_write_service: Arc<IntegrationsWriteService>,
13    /// The one OAuth generation service. `Option` because composition opts in
14    /// (`with_oauth` + the two ports); `oauth_routes()` is empty without it.
15    pub integrations_oauth: Option<Arc<IntegrationsOauthService>>,
16}
17
18/// State the builder adds.
19pub struct ModuleBuilderExt {
20    // OAuth generation fields (the one flow's construction inputs)
21    oauth_config: Option<IntegrationsOauthConfig>,
22    oauth_transport: Option<Arc<dyn OAuthTransport>>,
23    oauth_store: Option<Arc<dyn OAuthCredentialStore>>,
24}
25
26impl Default for ModuleBuilderExt {
27    fn default() -> Self {
28        Self {
29            oauth_config: None,
30            oauth_transport: None,
31            oauth_store: None,
32        }
33    }
34}
35
36impl ModuleBuilderExt {
37    /// Build the extension's state from what the generated build made.
38    #[allow(unused_variables, clippy::redundant_clone)]
39    pub(crate) fn build(self, parts: &ModuleParts<'_>) -> anyhow::Result<ModuleExt> {
40        let db_pool = parts.db_pool.clone();
41        // IntegrationsWrite service — the hand-authored receive/retry/map path; constructed alongside the
42        // generated CRUD services so the module ships its whole public surface from the builder.
43        let integrations_write_service = Arc::new(IntegrationsWriteService::new(db_pool.clone()));
44        // The one OAuth generation. Constructed only when composition opted in; a partial opt-in
45        // (config without both ports) is a build error, and IntegrationsOauthService::build
46        // re-validates everything fail-closed: the named env var must hold the state-signing key,
47        // public_base must be present, and every endpoint override must pass the guard.
48        let integrations_oauth = match (self.oauth_config, self.oauth_transport, self.oauth_store) {
49            (Some(config), transport, Some(store)) => {
50                let transport = match transport {
51                    Some(t) => t,
52                    None => Arc::new(ReqwestOAuthTransport::new()
53                        .map_err(|e| anyhow::anyhow!("building the default OAuth transport: {e}"))?),
54                };
55                Some(Arc::new(IntegrationsOauthService::build(
56                    db_pool.clone(),
57                    config,
58                    transport,
59                    store,
60                )?))
61            }
62            (Some(_), _, None) => {
63                return Err(anyhow::anyhow!(
64                    "with_oauth needs the credential store bound through the port: with_oauth_store(..)"
65                ))
66            }
67            (None, _, _) => None,
68        };
69        Ok(ModuleExt {
70            integrations_write_service,
71            integrations_oauth,
72        })
73    }
74}
75
76impl crate::ThisModule {
77    /// Production-safe router: connector admin CRUD + event READ-only.
78    ///
79    /// Integration events are produced by the idempotent `receive_event` path, not
80    /// hand-mutated, so the generic event write/bulk endpoints are deliberately
81    /// excluded here — a caller cannot bypass the dedup/map state machine through
82    /// this surface. Mount this (or `readonly_routes()`) for production; reserve
83    /// `all_crud_routes()` for trusted/admin/seeding only.
84    pub fn guarded_routes(&self) -> Router {
85        use presentation::http::{
86            create_integration_connector_routes,
87            create_integration_event_read_routes,
88        };
89        Router::new()
90            .merge(create_integration_connector_routes(self.integration_connector_service.clone()))
91            .merge(create_integration_event_read_routes(self.integration_event_service.clone()))
92    }
93    /// The verb-shaped OAuth surface (authorize / callback / complete /
94    /// disconnect / status). Empty when the module was built without
95    /// `with_oauth` — mounting it anyway is safe, it contributes no routes.
96    pub fn oauth_routes(&self) -> Router {
97        use presentation::http::create_oauth_routes;
98        match &self.integrations_oauth {
99            Some(service) => create_oauth_routes(service.clone()),
100            None => Router::new(),
101        }
102    }
103}
104
105impl crate::ThisModuleBuilder {
106    /// Opt the module into the one OAuth generation. The config carries the
107    /// deployment's public base, per-provider clients, and endpoint overrides
108    /// — every override passes the endpoint guard at build time (a bad one
109    /// fails the build), and the state-signing key is read from the
110    /// environment variable the config names (never a file value).
111    pub fn with_oauth(mut self, config: IntegrationsOauthConfig) -> Self {
112        self.oauth_config = Some(config);
113        self
114    }
115    /// The outbound OAuth transport (token exchange + server-side identity
116    /// read). Defaults to [`ReqwestOAuthTransport`] when omitted.
117    pub fn with_oauth_transport(mut self, transport: Arc<dyn OAuthTransport>) -> Self {
118        self.oauth_transport = Some(transport);
119        self
120    }
121    /// Bind the credential store through the port (ADR-0024 amendment: no
122    /// Cargo edge — the host adapts its store onto these verbs).
123    pub fn with_oauth_store(mut self, store: Arc<dyn OAuthCredentialStore>) -> Self {
124        self.oauth_store = Some(store);
125        self
126    }
127}