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