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 (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
342pub fn ops_for(provider: &str) -> Option<&'static dyn ProviderOps> {
344 lookup(provider).map(|entry| entry.ops)
345}
346
347pub 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
355pub 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 #[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 #[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}