Skip to main content

stackless_integrations/providers/
clerk.rs

1//! Clerk integration via Stripe Projects (`clerk/auth`).
2
3use std::collections::BTreeMap;
4use std::path::Path;
5use std::time::Duration;
6
7use async_trait::async_trait;
8use serde::{Deserialize, Serialize};
9use stackless_core::def::{Namespace, StackDef};
10use stackless_core::substrate::StepResource;
11use stackless_core::types::DnsName;
12use stackless_provider_sdk::{
13    BlockedSetting, ConfigScope, Hostable, IntegrationError, IntegrationHosting,
14    IntegrationObservation, config_bool, config_optional_string, config_string, host_bound_hosts,
15};
16
17use stackless_stripe_projects::ProjectsError;
18use stackless_stripe_projects::catalog::verify::CatalogService;
19use stackless_stripe_projects::project;
20use stackless_stripe_projects::provision::{
21    ProvisionContext, ProvisionedCredentials, provision_with_credentials,
22};
23use stackless_stripe_projects::stripe::{CommandRunner, StripeProjects};
24
25pub const RESOURCE_KIND: &str = "integration-clerk";
26const CLERK_API_BASE: &str = "https://api.clerk.com/v1";
27
28#[derive(Debug, Serialize, Deserialize)]
29pub struct ClerkPayload {
30    pub stripe_resource: String,
31    pub app_name: String,
32    pub credential_set: String,
33    #[serde(default)]
34    pub organizations: bool,
35    pub outputs: BTreeMap<String, String>,
36}
37
38#[derive(Debug, Clone, Serialize)]
39pub struct ClerkCredentialOutputs {
40    pub publishable_key: String,
41    pub secret_key: String,
42}
43
44/// The typed `clerk/auth` `--config`. Field names ARE the catalog contract; the
45/// gap test pins them against the live `configuration_schema`.
46#[derive(Debug, Serialize)]
47pub struct ClerkAuthConfig {
48    pub app_name: String,
49    #[serde(skip_serializing_if = "Option::is_none")]
50    pub production_domain: Option<String>,
51}
52
53impl CatalogService for ClerkAuthConfig {
54    const REFERENCE: &'static str = "clerk/auth";
55}
56
57/// Env blob key candidates for a Clerk resource, resource-prefixed first so
58/// multi-resource Stripe naming (`E2E_CLERK_ENVIRONMENTS`) wins over shared
59/// provider keys (`CLERK_ENVIRONMENTS` / `CLERK_AUTH_ENVIRONMENTS`).
60fn clerk_env_keys(resource_name: &str) -> Vec<String> {
61    let resource_prefix = resource_name.to_ascii_uppercase().replace('-', "_");
62    vec![
63        format!("{resource_prefix}_AUTH_ENVIRONMENTS"),
64        format!("{resource_prefix}_ENVIRONMENTS"),
65        "CLERK_AUTH_ENVIRONMENTS".to_owned(),
66        "CLERK_ENVIRONMENTS".to_owned(),
67    ]
68}
69
70#[derive(Debug)]
71pub struct ClerkAuth;
72
73impl Hostable for ClerkAuth {
74    /// Stripe Projects catalog adapter (`clerk/auth`).
75    const PROVIDER: &'static str = "clerk";
76    /// Clerk runs on Clerk Cloud — not on the stack's `--on` host.
77    const HOSTING: IntegrationHosting = IntegrationHosting::Managed;
78    /// All Clerk settings are global; per-host tables are rejected.
79    const CONFIG_SCOPE: ConfigScope = ConfigScope::GlobalOnly;
80    /// Checkpoint kind recorded by [`provision_stripe`].
81    const RESOURCE_KIND: &'static str = RESOURCE_KIND;
82    /// Keys exposed via `${integrations.<name>.<output>}`.
83    const OUTPUTS: &'static [&'static str] = &["secret_key", "publishable_key"];
84    /// Sign-in identifiers have no Clerk secret-key Backend API endpoint (only
85    /// `organizations` does), so they cannot be toggled during provisioning —
86    /// fail loud with the Dashboard remediation instead of silently ignoring the
87    /// key. See docs/DECISIONS.md.
88    const BLOCKED_SETTINGS: &'static [BlockedSetting] = &[
89        BlockedSetting {
90            key: "username",
91            remediation: "Clerk's secret-key Backend API cannot toggle sign-in identifiers. \
92                Enable username in the Clerk Dashboard \u{2192} User & authentication \u{2192} \
93                Username (Sign-in with username), or via `clerk config patch`.",
94        },
95        BlockedSetting {
96            key: "email",
97            remediation: "Clerk's secret-key Backend API cannot toggle sign-in identifiers. \
98                Configure the email identifier in the Clerk Dashboard \u{2192} User & \
99                authentication \u{2192} Email, or via `clerk config patch`.",
100        },
101        BlockedSetting {
102            key: "phone",
103            remediation: "Clerk's secret-key Backend API cannot toggle sign-in identifiers. \
104                Configure the phone identifier in the Clerk Dashboard \u{2192} User & \
105                authentication \u{2192} Phone, or via `clerk config patch`.",
106        },
107    ];
108}
109
110#[async_trait]
111impl crate::ProviderOps for ClerkAuth {
112    #[allow(clippy::too_many_arguments)]
113    async fn provision(
114        &self,
115        stripe: &StripeProjects<&dyn CommandRunner>,
116        def: &StackDef,
117        definition_dir: &Path,
118        instance: &str,
119        name: &str,
120        substrate: &str,
121        skip_stripe_instance_context: bool,
122    ) -> Result<StepResource, IntegrationError> {
123        provision_stripe(
124            stripe,
125            def,
126            definition_dir,
127            instance,
128            name,
129            substrate,
130            skip_stripe_instance_context,
131        )
132        .await
133    }
134
135    async fn apply(
136        &self,
137        _stripe: &StripeProjects<&dyn CommandRunner>,
138        _def: &StackDef,
139        _name: &str,
140        _substrate: &str,
141        provisioned: &StepResource,
142    ) -> Result<(), IntegrationError> {
143        let payload: ClerkPayload = serde_json::from_str(&provisioned.payload).map_err(|err| {
144            IntegrationError::ProvisionFailed {
145                integration: provisioned.resource_id.clone(),
146                detail: format!("Clerk checkpoint payload is invalid: {err}"),
147            }
148        })?;
149        if payload.organizations {
150            let secret_key = payload.outputs.get("secret_key").ok_or_else(|| {
151                IntegrationError::ProvisionFailed {
152                    integration: payload.stripe_resource.clone(),
153                    detail: "Clerk checkpoint missing secret_key output".into(),
154                }
155            })?;
156            enable_clerk_organizations(secret_key, &payload.stripe_resource).await?;
157        }
158        Ok(())
159    }
160
161    async fn observe(
162        &self,
163        stripe: &StripeProjects<&dyn CommandRunner>,
164        checkpoint_payload: &str,
165        fallback_resource: &str,
166    ) -> Result<IntegrationObservation, IntegrationError> {
167        observe(stripe, checkpoint_payload, fallback_resource).await
168    }
169
170    async fn destroy(
171        &self,
172        stripe: &StripeProjects<&dyn CommandRunner>,
173        checkpoint_payload: &str,
174        fallback_resource: &str,
175    ) -> Result<(), IntegrationError> {
176        destroy(stripe, checkpoint_payload, fallback_resource).await
177    }
178}
179
180/// Build the typed `clerk/auth` config from the integration definition.
181fn build_clerk_config(ctx: &ProvisionContext<'_>) -> Result<ClerkAuthConfig, ProjectsError> {
182    let resource = ctx.resource_name();
183    let fail = |detail: String| ProjectsError::ProvisionFailed {
184        resource: resource.clone(),
185        detail,
186    };
187    let spec = ctx
188        .def
189        .integrations
190        .get(ctx.logical_name)
191        .ok_or_else(|| fail("integration not in definition".into()))?;
192    let config = spec.effective_config(ctx.substrate, host_bound_hosts(ClerkAuth::HOSTING));
193    let app_name_raw = config_string(&config, "app_name").map_err(|err| fail(err.to_string()))?;
194    let namespace = Namespace {
195        stack_name: ctx.def.stack.name.clone(),
196        instance_name: DnsName::from_stored(ctx.instance),
197        ..Namespace::default()
198    };
199    let location = format!("integrations.{}.app_name", ctx.logical_name);
200    let app_name = stackless_core::def::interp::resolve(&app_name_raw, &namespace, &location)
201        .map_err(|err| fail(err.to_string()))?;
202    let production_domain = match config_optional_string(&config, "production_domain") {
203        None => None,
204        Some(domain) => {
205            let location = format!("integrations.{}.production_domain", ctx.logical_name);
206            Some(
207                stackless_core::def::interp::resolve(&domain, &namespace, &location)
208                    .map_err(|err| fail(err.to_string()))?,
209            )
210        }
211    };
212    Ok(ClerkAuthConfig {
213        app_name,
214        production_domain,
215    })
216}
217
218/// Parse the Clerk env blob into the credentials for the chosen environment.
219fn parse_clerk_credentials(
220    raw: &str,
221    credential_set: &str,
222    resource: &str,
223) -> Result<ClerkCredentialOutputs, ProjectsError> {
224    let parsed: ClerkAuthEnvironments =
225        serde_json::from_str(raw).map_err(|err| ProjectsError::ProvisionFailed {
226            resource: resource.to_owned(),
227            detail: format!("Clerk environments JSON is invalid: {err}"),
228        })?;
229    let credentials = match credential_set {
230        "development" => parsed.development,
231        "production" => parsed.production,
232        other => {
233            return Err(ProjectsError::ProvisionFailed {
234                resource: resource.to_owned(),
235                detail: format!("unknown Clerk credential_set {other:?}"),
236            });
237        }
238    };
239    let credentials = credentials.ok_or_else(|| ProjectsError::ProvisionFailed {
240        resource: resource.to_owned(),
241        detail: format!("Clerk environments JSON has no {credential_set} credentials"),
242    })?;
243    Ok(ClerkCredentialOutputs {
244        publishable_key: credentials.publishable_key,
245        secret_key: credentials.secret_key,
246    })
247}
248
249#[derive(Debug, Deserialize)]
250struct ClerkAuthEnvironments {
251    development: Option<ClerkCredentials>,
252    production: Option<ClerkCredentials>,
253}
254
255#[derive(Debug, Deserialize)]
256struct ClerkCredentials {
257    publishable_key: String,
258    secret_key: String,
259}
260
261pub fn validate_config(
262    name: &str,
263    config: &std::collections::BTreeMap<String, toml::Value>,
264) -> Result<(), IntegrationError> {
265    config_string(config, "app_name").map_err(|err| IntegrationError::ConfigInvalid {
266        location: format!("integrations.{name}.app_name"),
267        detail: err.to_string(),
268    })?;
269    let credential_set =
270        config_string(config, "credential_set").map_err(|err| IntegrationError::ConfigInvalid {
271            location: format!("integrations.{name}.credential_set"),
272            detail: err.to_string(),
273        })?;
274    match credential_set.as_str() {
275        "development" => Ok(()),
276        "production" => {
277            if config_optional_string(config, "production_domain").is_none() {
278                Err(IntegrationError::ConfigInvalid {
279                    location: format!("integrations.{name}.production_domain"),
280                    detail: "credential_set = \"production\" requires production_domain".into(),
281                })
282            } else {
283                Ok(())
284            }
285        }
286        other => Err(IntegrationError::ConfigInvalid {
287            location: format!("integrations.{name}.credential_set"),
288            detail: format!(
289                "credential_set must be \"development\" or \"production\", got {other:?}"
290            ),
291        }),
292    }
293}
294
295pub async fn provision_stripe<R: CommandRunner>(
296    stripe: &StripeProjects<R>,
297    def: &StackDef,
298    definition_dir: &Path,
299    instance: &str,
300    name: &str,
301    substrate: &str,
302    skip_instance_context: bool,
303) -> Result<StepResource, IntegrationError> {
304    if !def.integrations.contains_key(name) {
305        return Err(IntegrationError::ConfigInvalid {
306            location: format!("integrations.{name}"),
307            detail: "integration not in definition".into(),
308        });
309    }
310    let ctx = ProvisionContext {
311        def,
312        instance,
313        logical_name: name,
314        definition_dir,
315        substrate,
316        skip_instance_context,
317    };
318    let config = build_clerk_config(&ctx)?;
319    let app_name = config.app_name.clone();
320    let catalog = stripe.catalog().await?;
321    let env_key_owned = clerk_env_keys(&ctx.resource_name());
322    let env_keys: Vec<&str> = env_key_owned.iter().map(String::as_str).collect();
323    let ProvisionedCredentials { resource_name, raw } =
324        provision_with_credentials(stripe, &catalog, &ctx, &config, &env_keys).await?;
325
326    let spec = &def.integrations[name];
327    let effective = spec.effective_config(substrate, host_bound_hosts(ClerkAuth::HOSTING));
328    let credential_set = config_string(&effective, "credential_set").map_err(|err| {
329        IntegrationError::ConfigInvalid {
330            location: format!("integrations.{name}.credential_set"),
331            detail: err.to_string(),
332        }
333    })?;
334    let outputs = parse_clerk_credentials(&raw, &credential_set, &resource_name)?;
335
336    let organizations = config_bool(&effective, "organizations");
337
338    let mut output_map = BTreeMap::new();
339    output_map.insert("publishable_key".to_owned(), outputs.publishable_key);
340    output_map.insert("secret_key".to_owned(), outputs.secret_key);
341
342    let payload = ClerkPayload {
343        stripe_resource: resource_name.clone(),
344        app_name,
345        credential_set,
346        organizations,
347        outputs: output_map,
348    };
349    Ok(StepResource {
350        resource_kind: RESOURCE_KIND.into(),
351        resource_id: resource_name,
352        payload: serde_json::to_string(&payload).unwrap_or_default(),
353    })
354}
355
356pub async fn observe<R: CommandRunner>(
357    stripe: &StripeProjects<R>,
358    checkpoint_payload: &str,
359    fallback_resource: &str,
360) -> Result<IntegrationObservation, IntegrationError> {
361    let resource = stripe_resource(checkpoint_payload).unwrap_or_else(|| fallback_resource.into());
362    let present = project::resource_registered(stripe, &resource).await?;
363    Ok(if present {
364        IntegrationObservation::Present { drift: vec![] }
365    } else {
366        IntegrationObservation::Gone
367    })
368}
369
370pub async fn destroy<R: CommandRunner>(
371    stripe: &StripeProjects<R>,
372    checkpoint_payload: &str,
373    fallback_resource: &str,
374) -> Result<(), IntegrationError> {
375    let resource = stripe_resource(checkpoint_payload).unwrap_or_else(|| fallback_resource.into());
376    project::remove_resource(stripe, &resource).await?;
377    Ok(())
378}
379
380fn stripe_resource(payload: &str) -> Option<String> {
381    serde_json::from_str::<ClerkPayload>(payload)
382        .ok()
383        .map(|payload| payload.stripe_resource)
384}
385
386async fn enable_clerk_organizations(
387    secret_key: &str,
388    resource: &str,
389) -> Result<(), IntegrationError> {
390    update_clerk_organization_settings(CLERK_API_BASE, secret_key, true, resource).await
391}
392
393async fn update_clerk_organization_settings(
394    base: &str,
395    secret_key: &str,
396    enabled: bool,
397    resource: &str,
398) -> Result<(), IntegrationError> {
399    let url = format!(
400        "{}/instance/organization_settings",
401        base.trim_end_matches('/')
402    );
403    let response = reqwest::Client::new()
404        .patch(url)
405        .bearer_auth(secret_key)
406        .header(reqwest::header::ACCEPT, "application/json")
407        .json(&serde_json::json!({
408            "enabled": enabled,
409            "slug_disabled": !enabled,
410        }))
411        .timeout(Duration::from_secs(30))
412        .send()
413        .await
414        .map_err(|err| IntegrationError::ProvisionFailed {
415            integration: resource.to_owned(),
416            detail: format!("Clerk organization settings request failed: {err}"),
417        })?;
418    let status = response.status();
419    let text = response
420        .text()
421        .await
422        .map_err(|err| IntegrationError::ProvisionFailed {
423            integration: resource.to_owned(),
424            detail: format!("Clerk organization settings response failed: {err}"),
425        })?;
426    if !status.is_success() {
427        return Err(IntegrationError::ProvisionFailed {
428            integration: resource.to_owned(),
429            detail: format!(
430                "Clerk organization settings update failed: {}: {}",
431                status.as_u16(),
432                text.chars().take(300).collect::<String>()
433            ),
434        });
435    }
436    Ok(())
437}
438
439#[cfg(test)]
440mod tests {
441    use super::*;
442    use stackless_stripe_projects::stripe::{CommandOutput, StripeProjects};
443    use stackless_stripe_projects::test_support::ScriptedRunner;
444    use wiremock::matchers::{body_json, header, method, path};
445    use wiremock::{Mock, MockServer, ResponseTemplate};
446
447    fn out(stdout: &str) -> CommandOutput {
448        CommandOutput {
449            status: 0,
450            stdout: stdout.to_owned(),
451            stderr: String::new(),
452        }
453    }
454
455    /// Catalog gap check: `ClerkAuthConfig` must validate against the live
456    /// `clerk/auth` schema in the committed catalog fixture.
457    #[test]
458    fn clerk_config_matches_catalog() {
459        const FIXTURE: &str = include_str!(concat!(
460            env!("CARGO_MANIFEST_DIR"),
461            "/../stackless-stripe-projects/tests/fixtures/catalog.json"
462        ));
463        let catalog = stackless_stripe_projects::Catalog::from_json_envelope(FIXTURE).unwrap();
464        let failures = stackless_stripe_projects::verify_service(
465            &catalog,
466            &ClerkAuthConfig {
467                app_name: "atto-demo".into(),
468                production_domain: None,
469            },
470        );
471        assert!(
472            failures.is_empty(),
473            "clerk catalog gaps:\n{}",
474            failures.join("\n")
475        );
476    }
477
478    /// A minimal `stripe projects catalog --json` envelope carrying `clerk/auth`.
479    const CLERK_CATALOG_ENVELOPE: &str = r#"{"ok":true,"command":"projects catalog","data":{
480        "last_updated":"2026-06-12T00:00:00Z","services":[{
481            "id":"prvsvc_clerk","object":"v2.provisioning.provider_service_detail",
482            "provider_id":"prvdr_clerk","provider_name":"Clerk","service_id":"auth",
483            "categories":["auth"],"kind":"deployable","scope":"project","availability":"available",
484            "development":false,"livemode":true,"pricing":{"type":"component"},
485            "configuration_schema":{"type":"object","required":["app_name"],"additionalProperties":false,
486                "properties":{"app_name":{"type":"string"},"production_domain":{"type":"string"}}}
487        }]}}"#;
488
489    fn test_def() -> StackDef {
490        StackDef::parse(
491            r#"
492[stack]
493name = "atto"
494[stack.projects.stripe]
495project = "project_1"
496
497[integrations.clerk]
498provider = "clerk"
499app_name = "${stack.name}-${instance.name}"
500credential_set = "development"
501
502[services.api]
503source = { repo = "r", ref = "main" }
504env = { CLERK_SECRET_KEY = "${integrations.clerk.secret_key}" }
505health = { path = "/health" }
506[services.api.local]
507run = "true"
508"#,
509        )
510        .unwrap()
511    }
512
513    #[tokio::test]
514    async fn provision_clerk_adds_resource_and_records_outputs() {
515        let auth_env = serde_json::json!({
516            "development": {
517                "publishable_key": "pk_test_123",
518                "secret_key": "sk_test_123"
519            }
520        })
521        .to_string();
522        let runner = ScriptedRunner::new(vec![
523            out(CLERK_CATALOG_ENVELOPE),
524            out(r#"{"ok":true,"data":{"project":{"id":"project_1"}}}"#),
525            out(r#"{"ok":true,"data":{"environments":[{"name":"demo"}]}}"#),
526            out(r#"{"ok":true,"data":null}"#),
527            out(r#"{"ok":true,"data":{"services":[]}}"#),
528            out(&serde_json::json!({
529                "ok": true,
530                "data": {
531                    "variables": {
532                        "CLERK_AUTH_ENVIRONMENTS": auth_env
533                    }
534                }
535            })
536            .to_string()),
537            out(r#"{"ok":true,"data":null}"#),
538        ]);
539        let dir = tempfile::tempdir().unwrap();
540        std::fs::write(
541            dir.path().join("stackless.toml"),
542            "[stack]\nname=\"atto\"\n",
543        )
544        .unwrap();
545        let stripe = StripeProjects::new(&runner, dir.path());
546        let resource = provision_stripe(
547            &stripe,
548            &test_def(),
549            dir.path(),
550            "demo",
551            "clerk",
552            "local",
553            false,
554        )
555        .await
556        .unwrap();
557
558        assert_eq!(resource.resource_kind, "integration-clerk");
559        assert_eq!(resource.resource_id, "demo-clerk");
560        let payload: ClerkPayload = serde_json::from_str(&resource.payload).unwrap();
561        assert_eq!(payload.app_name, "atto-demo");
562        assert!(!payload.organizations);
563        assert_eq!(payload.outputs["secret_key"], "sk_test_123");
564        assert_eq!(payload.outputs["publishable_key"], "pk_test_123");
565
566        let calls = runner.calls();
567        assert!(calls.iter().any(|call| {
568            call.starts_with(&[
569                "add".to_owned(),
570                "clerk/auth".to_owned(),
571                "--name".to_owned(),
572                "demo-clerk".to_owned(),
573            ])
574        }));
575    }
576
577    #[test]
578    fn clerk_env_keys_prefer_resource_prefix() {
579        assert_eq!(
580            clerk_env_keys("e2e-clerk"),
581            [
582                "E2E_CLERK_AUTH_ENVIRONMENTS",
583                "E2E_CLERK_ENVIRONMENTS",
584                "CLERK_AUTH_ENVIRONMENTS",
585                "CLERK_ENVIRONMENTS",
586            ]
587        );
588    }
589
590    #[tokio::test]
591    async fn provision_clerk_prefers_resource_prefixed_env_blob() {
592        let resource_env = serde_json::json!({
593            "development": {
594                "publishable_key": "pk_resource",
595                "secret_key": "sk_resource"
596            }
597        })
598        .to_string();
599        let provider_env = serde_json::json!({
600            "development": {
601                "publishable_key": "pk_provider",
602                "secret_key": "sk_provider"
603            }
604        })
605        .to_string();
606        let runner = ScriptedRunner::new(vec![
607            out(CLERK_CATALOG_ENVELOPE),
608            out(r#"{"ok":true,"data":{"project":{"id":"project_1"}}}"#),
609            out(r#"{"ok":true,"data":{"environments":[{"name":"demo"}]}}"#),
610            out(r#"{"ok":true,"data":null}"#),
611            out(r#"{"ok":true,"data":{"services":[]}}"#),
612            out(&serde_json::json!({
613                "ok": true,
614                "data": {
615                    "variables": {
616                        "DEMO_CLERK_ENVIRONMENTS": resource_env,
617                        "CLERK_ENVIRONMENTS": provider_env
618                    }
619                }
620            })
621            .to_string()),
622            out(r#"{"ok":true,"data":null}"#),
623        ]);
624        let dir = tempfile::tempdir().unwrap();
625        std::fs::write(
626            dir.path().join("stackless.toml"),
627            "[stack]\nname=\"atto\"\n",
628        )
629        .unwrap();
630        let stripe = StripeProjects::new(&runner, dir.path());
631        let resource = provision_stripe(
632            &stripe,
633            &test_def(),
634            dir.path(),
635            "demo",
636            "clerk",
637            "local",
638            false,
639        )
640        .await
641        .unwrap();
642        let payload: ClerkPayload = serde_json::from_str(&resource.payload).unwrap();
643        assert_eq!(payload.outputs["secret_key"], "sk_resource");
644        assert_eq!(payload.outputs["publishable_key"], "pk_resource");
645    }
646
647    /// Shared `CLERK_AUTH_ENVIRONMENTS` must not shadow a resource-prefixed
648    /// `*_ENVIRONMENTS` blob (first-hit wins in `resolve_env_blob`).
649    #[tokio::test]
650    async fn provision_clerk_resource_env_wins_over_shared_auth_env() {
651        let resource_env = serde_json::json!({
652            "development": {
653                "publishable_key": "pk_resource",
654                "secret_key": "sk_resource"
655            }
656        })
657        .to_string();
658        let shared_auth_env = serde_json::json!({
659            "development": {
660                "publishable_key": "pk_shared_auth",
661                "secret_key": "sk_shared_auth"
662            }
663        })
664        .to_string();
665        let runner = ScriptedRunner::new(vec![
666            out(CLERK_CATALOG_ENVELOPE),
667            out(r#"{"ok":true,"data":{"project":{"id":"project_1"}}}"#),
668            out(r#"{"ok":true,"data":{"environments":[{"name":"demo"}]}}"#),
669            out(r#"{"ok":true,"data":null}"#),
670            out(r#"{"ok":true,"data":{"services":[]}}"#),
671            out(&serde_json::json!({
672                "ok": true,
673                "data": {
674                    "variables": {
675                        "DEMO_CLERK_ENVIRONMENTS": resource_env,
676                        "CLERK_AUTH_ENVIRONMENTS": shared_auth_env
677                    }
678                }
679            })
680            .to_string()),
681            out(r#"{"ok":true,"data":null}"#),
682        ]);
683        let dir = tempfile::tempdir().unwrap();
684        std::fs::write(
685            dir.path().join("stackless.toml"),
686            "[stack]\nname=\"atto\"\n",
687        )
688        .unwrap();
689        let stripe = StripeProjects::new(&runner, dir.path());
690        let resource = provision_stripe(
691            &stripe,
692            &test_def(),
693            dir.path(),
694            "demo",
695            "clerk",
696            "local",
697            false,
698        )
699        .await
700        .unwrap();
701        let payload: ClerkPayload = serde_json::from_str(&resource.payload).unwrap();
702        assert_eq!(payload.outputs["secret_key"], "sk_resource");
703        assert_eq!(payload.outputs["publishable_key"], "pk_resource");
704    }
705
706    #[tokio::test]
707    async fn observe_and_destroy_use_stripe_resource_from_payload() {
708        let payload = serde_json::to_string(&ClerkPayload {
709            stripe_resource: "demo-clerk".into(),
710            app_name: "atto-demo".into(),
711            credential_set: "development".into(),
712            organizations: true,
713            outputs: BTreeMap::new(),
714        })
715        .unwrap();
716        let runner = ScriptedRunner::new(vec![
717            // observe → services list
718            out(r#"{"ok":true,"data":{"services":[{"name":"demo-clerk"}]}}"#),
719            // destroy → remove_resource's registration pre-check (services list)
720            out(r#"{"ok":true,"data":{"services":[{"name":"demo-clerk"}]}}"#),
721            // destroy → the actual remove
722            out(r#"{"ok":true,"data":null}"#),
723        ]);
724        let stripe = StripeProjects::new(&runner, std::env::temp_dir());
725
726        assert_eq!(
727            observe(&stripe, &payload, "fallback").await.unwrap(),
728            IntegrationObservation::Present { drift: vec![] }
729        );
730        destroy(&stripe, &payload, "fallback").await.unwrap();
731        let calls = runner.calls();
732        assert!(
733            calls
734                .iter()
735                .any(|call| call.starts_with(&["remove".to_owned(), "demo-clerk".to_owned()]))
736        );
737    }
738
739    /// `apply` is a no-op when the checkpoint records `organizations = false`
740    /// (no Clerk API call, no Stripe call), and fails loudly on a corrupt
741    /// payload instead of silently skipping the toggle.
742    #[tokio::test]
743    async fn apply_skips_toggle_when_organizations_disabled() {
744        use crate::ProviderOps;
745
746        let payload = serde_json::to_string(&ClerkPayload {
747            stripe_resource: "demo-clerk".into(),
748            app_name: "atto-demo".into(),
749            credential_set: "development".into(),
750            organizations: false,
751            outputs: BTreeMap::new(),
752        })
753        .unwrap();
754        let provisioned = StepResource {
755            resource_kind: RESOURCE_KIND.into(),
756            resource_id: "demo-clerk".into(),
757            payload,
758        };
759        let runner = ScriptedRunner::new(vec![]);
760        let stripe = StripeProjects::new(&runner, std::env::temp_dir());
761        ClerkAuth
762            .apply(
763                &stripe.as_dyn(),
764                &test_def(),
765                "clerk",
766                "local",
767                &provisioned,
768            )
769            .await
770            .unwrap();
771        assert!(runner.calls().is_empty(), "apply must not touch Stripe");
772
773        let corrupt = StepResource {
774            resource_kind: RESOURCE_KIND.into(),
775            resource_id: "demo-clerk".into(),
776            payload: "not-json".into(),
777        };
778        let err = ClerkAuth
779            .apply(&stripe.as_dyn(), &test_def(), "clerk", "local", &corrupt)
780            .await
781            .unwrap_err();
782        assert!(err.to_string().contains("payload is invalid"));
783    }
784
785    #[tokio::test]
786    async fn enabling_clerk_organizations_patches_instance_settings() {
787        let server = MockServer::start().await;
788        Mock::given(method("PATCH"))
789            .and(path("/instance/organization_settings"))
790            .and(header("authorization", "Bearer sk_test_123"))
791            .and(body_json(serde_json::json!({
792                "enabled": true,
793                "slug_disabled": false,
794            })))
795            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
796                "object": "organization_settings",
797                "enabled": true
798            })))
799            .mount(&server)
800            .await;
801
802        update_clerk_organization_settings(&server.uri(), "sk_test_123", true, "demo-clerk")
803            .await
804            .unwrap();
805    }
806}