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    (shopify::store, ShopifyStore),
130    (steel::browser, SteelBrowser),
131    (supabase::project, SupabaseProject),
132    (supermemory::memory, SupermemoryMemory),
133    (tabstack::api, TabstackApi),
134    (turso::database, TursoDatabase),
135    (upstash::qstash, UpstashQstash),
136    (upstash::redis, UpstashRedis),
137    (upstash::search, UpstashSearch),
138    (upstash::vector, UpstashVector),
139    (wix::headless, WixHeadless),
140    (wordpress_com::site, WordPressComSite),
141    (workos::auth, WorkOSAuth),
142}
143
144fn lookup(provider: &str) -> Option<&'static ProviderEntry> {
145    PROVIDERS.iter().find(|entry| entry.provider == provider)
146}
147
148pub fn is_integration_resource(kind: &str) -> bool {
149    PROVIDERS.iter().any(|entry| entry.resource_kind == kind)
150}
151
152pub fn known_outputs(provider: &str) -> Option<&'static [&'static str]> {
153    lookup(provider).map(|entry| entry.outputs)
154}
155
156pub fn validate_integration(
157    name: &str,
158    integration: &Integration,
159    active_host: Option<&str>,
160    known_substrates: &[&str],
161) -> Result<(), IntegrationError> {
162    let entry = lookup(&integration.provider).ok_or_else(|| IntegrationError::ConfigInvalid {
163        location: format!("integrations.{name}"),
164        detail: format!("unsupported provider {:?}", integration.provider),
165    })?;
166
167    validate_host_blocks(
168        name,
169        integration,
170        entry.hosting,
171        entry.config_scope,
172        known_substrates,
173    )?;
174
175    if let Some(host) = active_host
176        && matches!(entry.hosting, IntegrationHosting::HostBound(_))
177        && !host_bound_supports(entry.hosting, host)
178    {
179        return Err(IntegrationError::HostUnsupported {
180            provider: integration.provider.clone(),
181            host: host.to_owned(),
182        });
183    }
184
185    let config = match (entry.config_scope, active_host) {
186        (ConfigScope::PerHost, Some(host)) => integration.effective_config(host, known_substrates),
187        _ => integration.config_fields(known_substrates),
188    };
189    (entry.validate_config)(name, &config)?;
190
191    for setting in entry.blocked_settings {
192        if config_bool(&config, setting.key) {
193            return Err(IntegrationError::ConfigInvalid {
194                location: format!("integrations.{name}.{}", setting.key),
195                detail: setting.remediation.to_owned(),
196            });
197        }
198    }
199    Ok(())
200}
201
202pub fn validate_all(
203    def: &StackDef,
204    active_host: Option<&str>,
205    known_substrates: &[&str],
206) -> Result<(), IntegrationError> {
207    for (name, integration) in &def.integrations {
208        validate_integration(name, integration, active_host, known_substrates)?;
209    }
210    validate_integration_outputs(def, known_substrates)?;
211    Ok(())
212}
213
214fn validate_host_blocks(
215    name: &str,
216    integration: &Integration,
217    hosting: IntegrationHosting,
218    scope: ConfigScope,
219    known_substrates: &[&str],
220) -> Result<(), IntegrationError> {
221    for (host, _block) in integration.host_blocks(known_substrates) {
222        if matches!(hosting, IntegrationHosting::Managed) {
223            return Err(IntegrationError::ConfigInvalid {
224                location: format!("integrations.{name}.{host}"),
225                detail: format!(
226                    "provider {:?} is managed and does not support per-host configuration",
227                    integration.provider
228                ),
229            });
230        }
231        if matches!(scope, ConfigScope::GlobalOnly) {
232            return Err(IntegrationError::ConfigInvalid {
233                location: format!("integrations.{name}.{host}"),
234                detail: format!(
235                    "provider {:?} does not support per-host configuration",
236                    integration.provider
237                ),
238            });
239        }
240        if !host_bound_supports(hosting, &host) {
241            return Err(IntegrationError::ConfigInvalid {
242                location: format!("integrations.{name}.{host}"),
243                detail: format!(
244                    "host {host:?} is not supported by provider {:?}",
245                    integration.provider
246                ),
247            });
248        }
249        let _ = host_bound_hosts(hosting);
250    }
251    Ok(())
252}
253
254fn validate_integration_outputs(
255    def: &StackDef,
256    known_substrates: &[&str],
257) -> Result<(), IntegrationError> {
258    let mut locations = Vec::new();
259    if let Some(verify) = &def.stack.verify {
260        for (key, value) in &verify.env {
261            locations.push((format!("stack.verify.env.{key}"), value.clone()));
262        }
263        for (tier, spec) in &verify.tiers {
264            for (key, value) in &spec.env {
265                locations.push((
266                    format!("stack.verify.tiers.{tier}.env.{key}"),
267                    value.clone(),
268                ));
269            }
270        }
271    }
272    for (service_name, service) in &def.services {
273        for (key, value) in &service.env {
274            locations.push((format!("services.{service_name}.env.{key}"), value.clone()));
275        }
276        for &host in known_substrates {
277            for (key, value) in service.substrate_env(service_name, host).map_err(|err| {
278                IntegrationError::ConfigInvalid {
279                    location: format!("services.{service_name}.{host}.env"),
280                    detail: err.to_string(),
281                }
282            })? {
283                locations.push((format!("services.{service_name}.{host}.env.{key}"), value));
284            }
285        }
286    }
287    for (name, integration) in &def.integrations {
288        for (key, value) in integration.config_fields(known_substrates) {
289            if let Some(text) = value.as_str() {
290                locations.push((format!("integrations.{name}.{key}"), text.to_owned()));
291            }
292        }
293    }
294
295    for (location, value) in locations {
296        let refs = interp::references(&value, &location).map_err(|err| {
297            IntegrationError::ConfigInvalid {
298                location: location.clone(),
299                detail: err.to_string(),
300            }
301        })?;
302        for reference in refs {
303            let Reference::IntegrationOutput {
304                integration,
305                output,
306            } = reference
307            else {
308                continue;
309            };
310            let Some(spec) = def.integrations.get(&integration) else {
311                continue;
312            };
313            let outputs =
314                known_outputs(&spec.provider).ok_or_else(|| IntegrationError::ConfigInvalid {
315                    location: location.clone(),
316                    detail: format!("integration {integration:?} has unsupported provider"),
317                })?;
318            if !outputs.contains(&output.as_str()) {
319                return Err(IntegrationError::ConfigInvalid {
320                    location,
321                    detail: format!(
322                        "unknown output {output:?} for integration {integration:?} \
323                         (known: {outputs:?})"
324                    ),
325                });
326            }
327        }
328    }
329    Ok(())
330}
331
332pub fn dispatch_resource_kind(provider: &str) -> Option<&'static str> {
333    lookup(provider).map(|entry| entry.resource_kind)
334}
335
336/// The lifecycle ops for `provider`, for provisioning dispatch.
337pub fn ops_for(provider: &str) -> Option<&'static dyn ProviderOps> {
338    lookup(provider).map(|entry| entry.ops)
339}
340
341/// The lifecycle ops owning resources of `kind`, for observe/destroy dispatch.
342pub fn ops_for_resource_kind(kind: &str) -> Option<&'static dyn ProviderOps> {
343    PROVIDERS
344        .iter()
345        .find(|entry| entry.resource_kind == kind)
346        .map(|entry| entry.ops)
347}
348
349/// The substrate keys that count as host overrides for `provider`'s config —
350/// its declared host-bound hosts (empty for managed providers). The provision
351/// path uses this since it has no global substrate list; the authoritative
352/// misplaced-block check runs at `up`/`check` with the full substrate list.
353pub fn provider_host_keys(provider: &str) -> &'static [&'static str] {
354    lookup(provider)
355        .map(|entry| host_bound_hosts(entry.hosting))
356        .unwrap_or(&[])
357}
358
359#[cfg(test)]
360mod tests {
361    use stackless_core::def::StackDef;
362    use stackless_core::fault::{Fault, codes};
363
364    use super::*;
365
366    const KNOWN: &[&str] = &["local", "render", "vercel", "fly", "netlify"];
367
368    /// Registry hygiene: every provider string and resource kind is unique, so a
369    /// new `PROVIDERS` row can't silently shadow another's dispatch.
370    #[test]
371    fn registry_providers_and_resource_kinds_are_unique() {
372        use std::collections::BTreeSet;
373        let providers: BTreeSet<&str> = PROVIDERS.iter().map(|e| e.provider).collect();
374        assert_eq!(
375            providers.len(),
376            PROVIDERS.len(),
377            "duplicate provider string"
378        );
379        let kinds: BTreeSet<&str> = PROVIDERS.iter().map(|e| e.resource_kind).collect();
380        assert_eq!(kinds.len(), PROVIDERS.len(), "duplicate resource_kind");
381    }
382
383    #[test]
384    fn managed_provider_rejects_host_block() {
385        let def = StackDef::parse(
386            r#"
387[stack]
388name = "demo"
389[integrations.clerk]
390provider = "clerk"
391app_name = "demo"
392[integrations.clerk.render]
393credential_set = "development"
394[services.web]
395source = { repo = "https://example.invalid/web", ref = "main" }
396health = { path = "/" }
397[services.web.local]
398run = "true"
399"#,
400        )
401        .unwrap();
402        let err = validate_integration("clerk", &def.integrations["clerk"], Some("render"), KNOWN)
403            .unwrap_err();
404        assert_eq!(err.code(), codes::INTEGRATION_CONFIG_INVALID);
405        assert!(err.to_string().contains("managed"));
406    }
407
408    #[test]
409    fn global_only_managed_provider_rejects_local_host_block() {
410        let integration = Integration {
411            provider: "clerk".to_owned(),
412            fields: BTreeMap::from([
413                (
414                    "app_name".to_owned(),
415                    toml::Value::String("demo".to_owned()),
416                ),
417                (
418                    "local".to_owned(),
419                    toml::Value::Table(
420                        [(
421                            "app_name".to_owned(),
422                            toml::Value::String("override".to_owned()),
423                        )]
424                        .into_iter()
425                        .collect(),
426                    ),
427                ),
428            ]),
429        };
430        let err = validate_integration("clerk", &integration, None, KNOWN).unwrap_err();
431        assert_eq!(err.code(), codes::INTEGRATION_CONFIG_INVALID);
432    }
433
434    /// A provider's [`Hostable::BLOCKED_SETTINGS`] fail validation loudly (with
435    /// the remediation) instead of being silently ignored. Exercises the generic
436    /// `entry.blocked_settings` path, so it covers any future provider too.
437    #[test]
438    fn provider_rejects_blocked_setting() {
439        let def = StackDef::parse(
440            r#"
441[stack]
442name = "demo"
443[integrations.clerk]
444provider = "clerk"
445app_name = "demo"
446credential_set = "development"
447username = true
448[services.web]
449source = { repo = "https://example.invalid/web", ref = "main" }
450health = { path = "/" }
451[services.web.local]
452run = "true"
453"#,
454        )
455        .unwrap();
456        let err =
457            validate_integration("clerk", &def.integrations["clerk"], None, KNOWN).unwrap_err();
458        assert_eq!(err.code(), codes::INTEGRATION_CONFIG_INVALID);
459        assert!(err.to_string().contains("Sign-in with username"));
460    }
461}