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