Skip to main content

stackless_integrations/
registry.rs

1//! First-class integration provider registry: validation, outputs, dispatch.
2//!
3//! Validation rules:
4//! 1. `provider` must match a registered [`Hostable`] entry.
5//! 2. Host-key tables in `integration.fields` are rejected for [`IntegrationHosting::Managed`]
6//!    and for [`ConfigScope::GlobalOnly`]; for [`ConfigScope::PerHost`] only hosts in
7//!    [`IntegrationHosting::HostBound`] are allowed.
8//! 3. `check` / `up --on <host>` requires membership in the host list for host-bound providers;
9//!    managed providers skip this check.
10//! 4. Provider-specific config validation runs on the effective config for the active host.
11
12use std::collections::BTreeMap;
13
14use stackless_core::def::interp::{self, Reference};
15use stackless_core::def::{Integration, StackDef};
16use stackless_provider_sdk::{
17    BlockedSetting, ConfigScope, Hostable, IntegrationError, IntegrationHosting, ProviderOps,
18    host_bound_hosts, host_bound_supports,
19};
20pub use stackless_provider_sdk::{config_bool, config_optional_string, config_string};
21
22use crate::providers;
23
24type ValidateFn = fn(&str, &BTreeMap<String, toml::Value>) -> Result<(), IntegrationError>;
25
26/// One row in the provider table, materialized from a [`Hostable`] impl plus
27/// its lifecycle [`ProviderOps`]. The registry is the single source of truth
28/// for both metadata and behaviour — dispatch never matches provider strings.
29struct ProviderEntry {
30    provider: &'static str,
31    hosting: IntegrationHosting,
32    config_scope: ConfigScope,
33    resource_kind: &'static str,
34    outputs: &'static [&'static str],
35    blocked_settings: &'static [BlockedSetting],
36    validate_config: ValidateFn,
37    ops: &'static dyn ProviderOps,
38}
39
40const fn provider_entry<T: Hostable>(
41    validate_config: ValidateFn,
42    ops: &'static dyn ProviderOps,
43) -> ProviderEntry {
44    ProviderEntry {
45        provider: T::PROVIDER,
46        hosting: T::HOSTING,
47        config_scope: T::CONFIG_SCOPE,
48        resource_kind: T::RESOURCE_KIND,
49        outputs: T::OUTPUTS,
50        blocked_settings: T::BLOCKED_SETTINGS,
51        validate_config,
52        ops,
53    }
54}
55
56macro_rules! register_providers {
57    ( $( ( $($m:ident)::+ , $T:ident ) ),+ $(,)? ) => {
58        const PROVIDERS: &[ProviderEntry] = &[
59            $(
60                provider_entry::<providers::$($m)::+::$T>(
61                    providers::$($m)::+::validate_config,
62                    &providers::$($m)::+::$T,
63                ),
64            )+
65        ];
66    };
67}
68
69register_providers! {
70    (clerk, ClerkAuth),
71    (cloudflare::browser_run, CloudflareBrowserRun),
72    (cloudflare::d1, CloudflareD1),
73    (cloudflare::hyperdrive, CloudflareHyperdrive),
74    (cloudflare::kv, CloudflareKv),
75    (cloudflare::queues, CloudflareQueues),
76    (cloudflare::r2, CloudflareR2),
77    (cloudflare::workers, CloudflareWorkers),
78    (cloudflare::workers_ai, CloudflareWorkersAi),
79    (agentmail::api, AgentMailApi),
80    (agentphone::number, AgentPhoneNumber),
81    (amplitude::analytics, AmplitudeAnalytics),
82    (auth0::client, Auth0Client),
83    (base44_projects::app, Base44ProjectsApp),
84    (blaxel::sandbox, BlaxelSandbox),
85    (browserbase::project, BrowserbaseProject),
86    (chatbase::agent, ChatbaseAgent),
87    (clickhouse::cluster, ClickHouseClickhouse),
88    (clickhouse::postgres, ClickHousePostgres),
89    (composio::project, ComposioProject),
90    (customerio::workspace, CustomerioWorkspace),
91    (datadog::observability, DatadogObservability),
92    (depot::api, DepotApi),
93    (e2b::sandbox, E2BSandbox),
94    (elevenlabs::tts, ElevenLabsTts),
95    (exa::api, ExaApi),
96    (firecrawl::api, FirecrawlApi),
97    (flyio::mpg, FlyioMpg),
98    (flyio::sprite, FlyioSprite),
99    (gitlab::project, GitLabProject),
100    (huggingface::bucket, HuggingFaceBucket),
101    (huggingface::platform, HuggingFacePlatform),
102    (inngest::app, InngestApp),
103    (kernel::project, KERNELProject),
104    (laravel_cloud::application, LaravelCloudApplication),
105    (laravel_cloud::mysql, LaravelCloudMysql),
106    (laravel_cloud::valkey, LaravelCloudValkey),
107    (metronome::sandbox, MetronomeSandbox),
108    (mixpanel::analytics, MixpanelAnalytics),
109    (neon::postgres, NeonPostgres),
110    (openrouter::api, OpenRouterApi),
111    (parallel::api, ParallelApi),
112    (planetscale::mysql, PlanetScaleMysql),
113    (planetscale::postgresql, PlanetScalePostgresql),
114    (postalform::mail, PostalFormMail),
115    (posthog::analytics, PostHogAnalytics),
116    (prisma::database, PrismaDatabase),
117    (pydantic::logfire, PydanticLogfire),
118    (railway::bucket, RailwayBucket),
119    (railway::hosting, RailwayHosting),
120    (railway::mongo, RailwayMongo),
121    (railway::postgres, RailwayPostgres),
122    (railway::redis, RailwayRedis),
123    (render_db::postgres, RenderPostgres),
124    (revenuecat::app, RevenuecatApp),
125    (runloop::sandbox, RunloopSandbox),
126    (schematic::schematic_environment, SchematicEnvironment),
127    (sentry::project, SentryProject),
128    (sentry::seer, SentrySeer),
129    (steel::browser, SteelBrowser),
130    (supabase::project, SupabaseProject),
131    (supermemory::memory, SupermemoryMemory),
132    (tabstack::api, TabstackApi),
133    (turso::database, TursoDatabase),
134    (upstash::qstash, UpstashQstash),
135    (upstash::redis, UpstashRedis),
136    (upstash::search, UpstashSearch),
137    (upstash::vector, UpstashVector),
138    (wix::headless, WixHeadless),
139    (wordpress_com::site, WordPressComSite),
140    (workos::auth, WorkOSAuth),
141}
142
143fn lookup(provider: &str) -> Option<&'static ProviderEntry> {
144    PROVIDERS.iter().find(|entry| entry.provider == provider)
145}
146
147pub fn is_integration_resource(kind: &str) -> bool {
148    PROVIDERS.iter().any(|entry| entry.resource_kind == kind)
149}
150
151pub fn known_outputs(provider: &str) -> Option<&'static [&'static str]> {
152    lookup(provider).map(|entry| entry.outputs)
153}
154
155pub fn validate_integration(
156    name: &str,
157    integration: &Integration,
158    active_host: Option<&str>,
159    known_substrates: &[&str],
160) -> Result<(), IntegrationError> {
161    let entry = lookup(&integration.provider).ok_or_else(|| IntegrationError::ConfigInvalid {
162        location: format!("integrations.{name}"),
163        detail: format!("unsupported provider {:?}", integration.provider),
164    })?;
165
166    validate_host_blocks(
167        name,
168        integration,
169        entry.hosting,
170        entry.config_scope,
171        known_substrates,
172    )?;
173
174    if let Some(host) = active_host
175        && matches!(entry.hosting, IntegrationHosting::HostBound(_))
176        && !host_bound_supports(entry.hosting, host)
177    {
178        return Err(IntegrationError::HostUnsupported {
179            provider: integration.provider.clone(),
180            host: host.to_owned(),
181        });
182    }
183
184    let config = match (entry.config_scope, active_host) {
185        (ConfigScope::PerHost, Some(host)) => integration.effective_config(host, known_substrates),
186        _ => integration.config_fields(known_substrates),
187    };
188    (entry.validate_config)(name, &config)?;
189
190    for setting in entry.blocked_settings {
191        if config_bool(&config, setting.key) {
192            return Err(IntegrationError::ConfigInvalid {
193                location: format!("integrations.{name}.{}", setting.key),
194                detail: setting.remediation.to_owned(),
195            });
196        }
197    }
198    Ok(())
199}
200
201pub fn validate_all(
202    def: &StackDef,
203    active_host: Option<&str>,
204    known_substrates: &[&str],
205) -> Result<(), IntegrationError> {
206    for (name, integration) in &def.integrations {
207        validate_integration(name, integration, active_host, known_substrates)?;
208    }
209    validate_integration_outputs(def, known_substrates)?;
210    Ok(())
211}
212
213fn validate_host_blocks(
214    name: &str,
215    integration: &Integration,
216    hosting: IntegrationHosting,
217    scope: ConfigScope,
218    known_substrates: &[&str],
219) -> Result<(), IntegrationError> {
220    for (host, _block) in integration.host_blocks(known_substrates) {
221        if matches!(hosting, IntegrationHosting::Managed) {
222            return Err(IntegrationError::ConfigInvalid {
223                location: format!("integrations.{name}.{host}"),
224                detail: format!(
225                    "provider {:?} is managed and does not support per-host configuration",
226                    integration.provider
227                ),
228            });
229        }
230        if matches!(scope, ConfigScope::GlobalOnly) {
231            return Err(IntegrationError::ConfigInvalid {
232                location: format!("integrations.{name}.{host}"),
233                detail: format!(
234                    "provider {:?} does not support per-host configuration",
235                    integration.provider
236                ),
237            });
238        }
239        if !host_bound_supports(hosting, &host) {
240            return Err(IntegrationError::ConfigInvalid {
241                location: format!("integrations.{name}.{host}"),
242                detail: format!(
243                    "host {host:?} is not supported by provider {:?}",
244                    integration.provider
245                ),
246            });
247        }
248        let _ = host_bound_hosts(hosting);
249    }
250    Ok(())
251}
252
253fn validate_integration_outputs(
254    def: &StackDef,
255    known_substrates: &[&str],
256) -> Result<(), IntegrationError> {
257    let mut locations = Vec::new();
258    if let Some(verify) = &def.stack.verify {
259        for (key, value) in &verify.env {
260            locations.push((format!("stack.verify.env.{key}"), value.clone()));
261        }
262        for (tier, spec) in &verify.tiers {
263            for (key, value) in &spec.env {
264                locations.push((
265                    format!("stack.verify.tiers.{tier}.env.{key}"),
266                    value.clone(),
267                ));
268            }
269        }
270    }
271    for (service_name, service) in &def.services {
272        for (key, value) in &service.env {
273            locations.push((format!("services.{service_name}.env.{key}"), value.clone()));
274        }
275        for &host in known_substrates {
276            for (key, value) in service.substrate_env(service_name, host).map_err(|err| {
277                IntegrationError::ConfigInvalid {
278                    location: format!("services.{service_name}.{host}.env"),
279                    detail: err.to_string(),
280                }
281            })? {
282                locations.push((format!("services.{service_name}.{host}.env.{key}"), value));
283            }
284        }
285    }
286    for (name, integration) in &def.integrations {
287        for (key, value) in integration.config_fields(known_substrates) {
288            if let Some(text) = value.as_str() {
289                locations.push((format!("integrations.{name}.{key}"), text.to_owned()));
290            }
291        }
292    }
293
294    for (location, value) in locations {
295        let refs = interp::references(&value, &location).map_err(|err| {
296            IntegrationError::ConfigInvalid {
297                location: location.clone(),
298                detail: err.to_string(),
299            }
300        })?;
301        for reference in refs {
302            let Reference::IntegrationOutput {
303                integration,
304                output,
305            } = reference
306            else {
307                continue;
308            };
309            let Some(spec) = def.integrations.get(&integration) else {
310                continue;
311            };
312            let outputs =
313                known_outputs(&spec.provider).ok_or_else(|| IntegrationError::ConfigInvalid {
314                    location: location.clone(),
315                    detail: format!("integration {integration:?} has unsupported provider"),
316                })?;
317            if !outputs.contains(&output.as_str()) {
318                return Err(IntegrationError::ConfigInvalid {
319                    location,
320                    detail: format!(
321                        "unknown output {output:?} for integration {integration:?} \
322                         (known: {outputs:?})"
323                    ),
324                });
325            }
326        }
327    }
328    Ok(())
329}
330
331pub fn dispatch_resource_kind(provider: &str) -> Option<&'static str> {
332    lookup(provider).map(|entry| entry.resource_kind)
333}
334
335/// The lifecycle ops for `provider`, for provisioning dispatch.
336pub fn ops_for(provider: &str) -> Option<&'static dyn ProviderOps> {
337    lookup(provider).map(|entry| entry.ops)
338}
339
340/// The lifecycle ops owning resources of `kind`, for observe/destroy dispatch.
341pub fn ops_for_resource_kind(kind: &str) -> Option<&'static dyn ProviderOps> {
342    PROVIDERS
343        .iter()
344        .find(|entry| entry.resource_kind == kind)
345        .map(|entry| entry.ops)
346}
347
348/// The substrate keys that count as host overrides for `provider`'s config —
349/// its declared host-bound hosts (empty for managed providers). The provision
350/// path uses this since it has no global substrate list; the authoritative
351/// misplaced-block check runs at `up`/`check` with the full substrate list.
352pub fn provider_host_keys(provider: &str) -> &'static [&'static str] {
353    lookup(provider)
354        .map(|entry| host_bound_hosts(entry.hosting))
355        .unwrap_or(&[])
356}
357
358#[cfg(test)]
359mod tests {
360    use stackless_core::def::StackDef;
361    use stackless_core::fault::{Fault, codes};
362
363    use super::*;
364
365    const KNOWN: &[&str] = &["local", "render", "vercel", "fly", "netlify"];
366
367    /// Registry hygiene: every provider string and resource kind is unique, so a
368    /// new `PROVIDERS` row can't silently shadow another's dispatch.
369    #[test]
370    fn registry_providers_and_resource_kinds_are_unique() {
371        use std::collections::BTreeSet;
372        let providers: BTreeSet<&str> = PROVIDERS.iter().map(|e| e.provider).collect();
373        assert_eq!(
374            providers.len(),
375            PROVIDERS.len(),
376            "duplicate provider string"
377        );
378        let kinds: BTreeSet<&str> = PROVIDERS.iter().map(|e| e.resource_kind).collect();
379        assert_eq!(kinds.len(), PROVIDERS.len(), "duplicate resource_kind");
380    }
381
382    #[test]
383    fn managed_provider_rejects_host_block() {
384        let def = StackDef::parse(
385            r#"
386[stack]
387name = "demo"
388[integrations.clerk]
389provider = "clerk"
390app_name = "demo"
391[integrations.clerk.render]
392credential_set = "development"
393[services.web]
394source = { repo = "https://example.invalid/web", ref = "main" }
395health = { path = "/" }
396[services.web.local]
397run = "true"
398"#,
399        )
400        .unwrap();
401        let err = validate_integration("clerk", &def.integrations["clerk"], Some("render"), KNOWN)
402            .unwrap_err();
403        assert_eq!(err.code(), codes::INTEGRATION_CONFIG_INVALID);
404        assert!(err.to_string().contains("managed"));
405    }
406
407    #[test]
408    fn global_only_managed_provider_rejects_local_host_block() {
409        let integration = Integration {
410            provider: "clerk".to_owned(),
411            fields: BTreeMap::from([
412                (
413                    "app_name".to_owned(),
414                    toml::Value::String("demo".to_owned()),
415                ),
416                (
417                    "local".to_owned(),
418                    toml::Value::Table(
419                        [(
420                            "app_name".to_owned(),
421                            toml::Value::String("override".to_owned()),
422                        )]
423                        .into_iter()
424                        .collect(),
425                    ),
426                ),
427            ]),
428        };
429        let err = validate_integration("clerk", &integration, None, KNOWN).unwrap_err();
430        assert_eq!(err.code(), codes::INTEGRATION_CONFIG_INVALID);
431    }
432
433    /// A provider's [`Hostable::BLOCKED_SETTINGS`] fail validation loudly (with
434    /// the remediation) instead of being silently ignored. Exercises the generic
435    /// `entry.blocked_settings` path, so it covers any future provider too.
436    #[test]
437    fn provider_rejects_blocked_setting() {
438        let def = StackDef::parse(
439            r#"
440[stack]
441name = "demo"
442[integrations.clerk]
443provider = "clerk"
444app_name = "demo"
445credential_set = "development"
446username = true
447[services.web]
448source = { repo = "https://example.invalid/web", ref = "main" }
449health = { path = "/" }
450[services.web.local]
451run = "true"
452"#,
453        )
454        .unwrap();
455        let err =
456            validate_integration("clerk", &def.integrations["clerk"], None, KNOWN).unwrap_err();
457        assert_eq!(err.code(), codes::INTEGRATION_CONFIG_INVALID);
458        assert!(err.to_string().contains("Sign-in with username"));
459    }
460}