1use 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
26struct 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
340pub fn ops_for(provider: &str) -> Option<&'static dyn ProviderOps> {
342 lookup(provider).map(|entry| entry.ops)
343}
344
345pub 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
353pub 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 #[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 #[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}