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
321        .catalog_for_reference(ClerkAuthConfig::REFERENCE)
322        .await?;
323    let env_key_owned = clerk_env_keys(&ctx.resource_name());
324    let env_keys: Vec<&str> = env_key_owned.iter().map(String::as_str).collect();
325    let ProvisionedCredentials { resource_name, raw } =
326        provision_with_credentials(stripe, &catalog, &ctx, &config, &env_keys).await?;
327
328    let spec = &def.integrations[name];
329    let effective = spec.effective_config(substrate, host_bound_hosts(ClerkAuth::HOSTING));
330    let credential_set = config_string(&effective, "credential_set").map_err(|err| {
331        IntegrationError::ConfigInvalid {
332            location: format!("integrations.{name}.credential_set"),
333            detail: err.to_string(),
334        }
335    })?;
336    let outputs = parse_clerk_credentials(&raw, &credential_set, &resource_name)?;
337
338    let organizations = config_bool(&effective, "organizations");
339
340    let mut output_map = BTreeMap::new();
341    output_map.insert("publishable_key".to_owned(), outputs.publishable_key);
342    output_map.insert("secret_key".to_owned(), outputs.secret_key);
343
344    let payload = ClerkPayload {
345        stripe_resource: resource_name.clone(),
346        app_name,
347        credential_set,
348        organizations,
349        outputs: output_map,
350    };
351    Ok(StepResource {
352        resource_kind: RESOURCE_KIND.into(),
353        resource_id: resource_name,
354        payload: serde_json::to_string(&payload).unwrap_or_default(),
355    })
356}
357
358pub async fn observe<R: CommandRunner>(
359    stripe: &StripeProjects<R>,
360    checkpoint_payload: &str,
361    fallback_resource: &str,
362) -> Result<IntegrationObservation, IntegrationError> {
363    let resource = stripe_resource(checkpoint_payload).unwrap_or_else(|| fallback_resource.into());
364    let present = project::resource_registered(stripe, &resource).await?;
365    Ok(if present {
366        IntegrationObservation::Present { drift: vec![] }
367    } else {
368        IntegrationObservation::Gone
369    })
370}
371
372pub async fn destroy<R: CommandRunner>(
373    stripe: &StripeProjects<R>,
374    checkpoint_payload: &str,
375    fallback_resource: &str,
376) -> Result<(), IntegrationError> {
377    let resource = stripe_resource(checkpoint_payload).unwrap_or_else(|| fallback_resource.into());
378    project::remove_resource(stripe, &resource).await?;
379    Ok(())
380}
381
382fn stripe_resource(payload: &str) -> Option<String> {
383    serde_json::from_str::<ClerkPayload>(payload)
384        .ok()
385        .map(|payload| payload.stripe_resource)
386}
387
388async fn enable_clerk_organizations(
389    secret_key: &str,
390    resource: &str,
391) -> Result<(), IntegrationError> {
392    update_clerk_organization_settings(CLERK_API_BASE, secret_key, true, resource).await
393}
394
395async fn update_clerk_organization_settings(
396    base: &str,
397    secret_key: &str,
398    enabled: bool,
399    resource: &str,
400) -> Result<(), IntegrationError> {
401    let url = format!(
402        "{}/instance/organization_settings",
403        base.trim_end_matches('/')
404    );
405    let response = reqwest::Client::new()
406        .patch(url)
407        .bearer_auth(secret_key)
408        .header(reqwest::header::ACCEPT, "application/json")
409        .json(&serde_json::json!({
410            "enabled": enabled,
411            "slug_disabled": !enabled,
412        }))
413        .timeout(Duration::from_secs(30))
414        .send()
415        .await
416        .map_err(|err| IntegrationError::ProvisionFailed {
417            integration: resource.to_owned(),
418            detail: format!("Clerk organization settings request failed: {err}"),
419        })?;
420    let status = response.status();
421    let text = response
422        .text()
423        .await
424        .map_err(|err| IntegrationError::ProvisionFailed {
425            integration: resource.to_owned(),
426            detail: format!("Clerk organization settings response failed: {err}"),
427        })?;
428    if !status.is_success() {
429        return Err(IntegrationError::ProvisionFailed {
430            integration: resource.to_owned(),
431            detail: format!(
432                "Clerk organization settings update failed: {}: {}",
433                status.as_u16(),
434                text.chars().take(300).collect::<String>()
435            ),
436        });
437    }
438    Ok(())
439}
440
441#[cfg(test)]
442mod tests {
443    use super::*;
444    use stackless_stripe_projects::stripe::{CommandOutput, StripeProjects};
445    use stackless_stripe_projects::test_support::ScriptedRunner;
446    use wiremock::matchers::{body_json, header, method, path};
447    use wiremock::{Mock, MockServer, ResponseTemplate};
448
449    fn out(stdout: &str) -> CommandOutput {
450        CommandOutput {
451            status: 0,
452            stdout: stdout.to_owned(),
453            stderr: String::new(),
454        }
455    }
456
457    /// Catalog gap check: `ClerkAuthConfig` must validate against the live
458    /// `clerk/auth` schema in the committed catalog fixture.
459    #[test]
460    fn clerk_config_matches_catalog() {
461        const FIXTURE: &str = include_str!(concat!(
462            env!("CARGO_MANIFEST_DIR"),
463            "/../stackless-stripe-projects/tests/fixtures/catalog.json"
464        ));
465        let catalog = stackless_stripe_projects::Catalog::from_json_envelope(FIXTURE).unwrap();
466        let failures = stackless_stripe_projects::verify_service(
467            &catalog,
468            &ClerkAuthConfig {
469                app_name: "atto-demo".into(),
470                production_domain: None,
471            },
472        );
473        assert!(
474            failures.is_empty(),
475            "clerk catalog gaps:\n{}",
476            failures.join("\n")
477        );
478    }
479
480    /// A minimal `stripe projects catalog --json` envelope carrying `clerk/auth`.
481    const CLERK_CATALOG_ENVELOPE: &str = r#"{"ok":true,"command":"projects catalog","data":{
482        "last_updated":"2026-06-12T00:00:00Z","services":[{
483            "id":"prvsvc_clerk","object":"v2.provisioning.provider_service_detail",
484            "provider_id":"prvdr_clerk","provider_name":"Clerk","service_id":"auth",
485            "categories":["auth"],"kind":"deployable","scope":"project","availability":"available",
486            "development":false,"livemode":true,"pricing":{"type":"component"},
487            "configuration_schema":{"type":"object","required":["app_name"],"additionalProperties":false,
488                "properties":{"app_name":{"type":"string"},"production_domain":{"type":"string"}}}
489        }]}}"#;
490
491    fn test_def() -> StackDef {
492        StackDef::parse(
493            r#"
494[stack]
495name = "atto"
496[stack.projects.stripe]
497project = "project_1"
498
499[integrations.clerk]
500provider = "clerk"
501app_name = "${stack.name}-${instance.name}"
502credential_set = "development"
503
504[services.api]
505source = { repo = "r", ref = "main" }
506env = { CLERK_SECRET_KEY = "${integrations.clerk.secret_key}" }
507health = { path = "/health" }
508[services.api.local]
509run = "true"
510"#,
511        )
512        .unwrap()
513    }
514
515    #[tokio::test]
516    async fn provision_clerk_adds_resource_and_records_outputs() {
517        let auth_env = serde_json::json!({
518            "development": {
519                "publishable_key": "pk_test_123",
520                "secret_key": "sk_test_123"
521            }
522        })
523        .to_string();
524        let runner = ScriptedRunner::new(vec![
525            out(CLERK_CATALOG_ENVELOPE),
526            out(r#"{"ok":true,"data":{"project":{"id":"project_1"}}}"#),
527            out(r#"{"ok":true,"data":{"environments":[{"name":"demo"}]}}"#),
528            out(r#"{"ok":true,"data":null}"#),
529            out(r#"{"ok":true,"data":{"services":[]}}"#),
530            out(&serde_json::json!({
531                "ok": true,
532                "data": {
533                    "variables": {
534                        "CLERK_AUTH_ENVIRONMENTS": auth_env
535                    }
536                }
537            })
538            .to_string()),
539            out(r#"{"ok":true,"data":null}"#),
540        ]);
541        let dir = tempfile::tempdir().unwrap();
542        std::fs::write(
543            dir.path().join("stackless.toml"),
544            "[stack]\nname=\"atto\"\n",
545        )
546        .unwrap();
547        let stripe = StripeProjects::new(&runner, dir.path());
548        let resource = provision_stripe(
549            &stripe,
550            &test_def(),
551            dir.path(),
552            "demo",
553            "clerk",
554            "local",
555            false,
556        )
557        .await
558        .unwrap();
559
560        assert_eq!(resource.resource_kind, "integration-clerk");
561        assert_eq!(resource.resource_id, "demo-clerk");
562        let payload: ClerkPayload = serde_json::from_str(&resource.payload).unwrap();
563        assert_eq!(payload.app_name, "atto-demo");
564        assert!(!payload.organizations);
565        assert_eq!(payload.outputs["secret_key"], "sk_test_123");
566        assert_eq!(payload.outputs["publishable_key"], "pk_test_123");
567
568        let calls = runner.calls();
569        assert!(calls.iter().any(|call| {
570            call.starts_with(&[
571                "add".to_owned(),
572                "clerk/auth".to_owned(),
573                "--name".to_owned(),
574                "demo-clerk".to_owned(),
575            ])
576        }));
577    }
578
579    #[test]
580    fn clerk_env_keys_prefer_resource_prefix() {
581        assert_eq!(
582            clerk_env_keys("e2e-clerk"),
583            [
584                "E2E_CLERK_AUTH_ENVIRONMENTS",
585                "E2E_CLERK_ENVIRONMENTS",
586                "CLERK_AUTH_ENVIRONMENTS",
587                "CLERK_ENVIRONMENTS",
588            ]
589        );
590    }
591
592    #[tokio::test]
593    async fn provision_clerk_prefers_resource_prefixed_env_blob() {
594        let resource_env = serde_json::json!({
595            "development": {
596                "publishable_key": "pk_resource",
597                "secret_key": "sk_resource"
598            }
599        })
600        .to_string();
601        let provider_env = serde_json::json!({
602            "development": {
603                "publishable_key": "pk_provider",
604                "secret_key": "sk_provider"
605            }
606        })
607        .to_string();
608        let runner = ScriptedRunner::new(vec![
609            out(CLERK_CATALOG_ENVELOPE),
610            out(r#"{"ok":true,"data":{"project":{"id":"project_1"}}}"#),
611            out(r#"{"ok":true,"data":{"environments":[{"name":"demo"}]}}"#),
612            out(r#"{"ok":true,"data":null}"#),
613            out(r#"{"ok":true,"data":{"services":[]}}"#),
614            out(&serde_json::json!({
615                "ok": true,
616                "data": {
617                    "variables": {
618                        "DEMO_CLERK_ENVIRONMENTS": resource_env,
619                        "CLERK_ENVIRONMENTS": provider_env
620                    }
621                }
622            })
623            .to_string()),
624            out(r#"{"ok":true,"data":null}"#),
625        ]);
626        let dir = tempfile::tempdir().unwrap();
627        std::fs::write(
628            dir.path().join("stackless.toml"),
629            "[stack]\nname=\"atto\"\n",
630        )
631        .unwrap();
632        let stripe = StripeProjects::new(&runner, dir.path());
633        let resource = provision_stripe(
634            &stripe,
635            &test_def(),
636            dir.path(),
637            "demo",
638            "clerk",
639            "local",
640            false,
641        )
642        .await
643        .unwrap();
644        let payload: ClerkPayload = serde_json::from_str(&resource.payload).unwrap();
645        assert_eq!(payload.outputs["secret_key"], "sk_resource");
646        assert_eq!(payload.outputs["publishable_key"], "pk_resource");
647    }
648
649    /// Shared `CLERK_AUTH_ENVIRONMENTS` must not shadow a resource-prefixed
650    /// `*_ENVIRONMENTS` blob (first-hit wins in `resolve_env_blob`).
651    #[tokio::test]
652    async fn provision_clerk_resource_env_wins_over_shared_auth_env() {
653        let resource_env = serde_json::json!({
654            "development": {
655                "publishable_key": "pk_resource",
656                "secret_key": "sk_resource"
657            }
658        })
659        .to_string();
660        let shared_auth_env = serde_json::json!({
661            "development": {
662                "publishable_key": "pk_shared_auth",
663                "secret_key": "sk_shared_auth"
664            }
665        })
666        .to_string();
667        let runner = ScriptedRunner::new(vec![
668            out(CLERK_CATALOG_ENVELOPE),
669            out(r#"{"ok":true,"data":{"project":{"id":"project_1"}}}"#),
670            out(r#"{"ok":true,"data":{"environments":[{"name":"demo"}]}}"#),
671            out(r#"{"ok":true,"data":null}"#),
672            out(r#"{"ok":true,"data":{"services":[]}}"#),
673            out(&serde_json::json!({
674                "ok": true,
675                "data": {
676                    "variables": {
677                        "DEMO_CLERK_ENVIRONMENTS": resource_env,
678                        "CLERK_AUTH_ENVIRONMENTS": shared_auth_env
679                    }
680                }
681            })
682            .to_string()),
683            out(r#"{"ok":true,"data":null}"#),
684        ]);
685        let dir = tempfile::tempdir().unwrap();
686        std::fs::write(
687            dir.path().join("stackless.toml"),
688            "[stack]\nname=\"atto\"\n",
689        )
690        .unwrap();
691        let stripe = StripeProjects::new(&runner, dir.path());
692        let resource = provision_stripe(
693            &stripe,
694            &test_def(),
695            dir.path(),
696            "demo",
697            "clerk",
698            "local",
699            false,
700        )
701        .await
702        .unwrap();
703        let payload: ClerkPayload = serde_json::from_str(&resource.payload).unwrap();
704        assert_eq!(payload.outputs["secret_key"], "sk_resource");
705        assert_eq!(payload.outputs["publishable_key"], "pk_resource");
706    }
707
708    #[tokio::test]
709    async fn observe_and_destroy_use_stripe_resource_from_payload() {
710        let payload = serde_json::to_string(&ClerkPayload {
711            stripe_resource: "demo-clerk".into(),
712            app_name: "atto-demo".into(),
713            credential_set: "development".into(),
714            organizations: true,
715            outputs: BTreeMap::new(),
716        })
717        .unwrap();
718        let runner = ScriptedRunner::new(vec![
719            // observe → services list
720            out(r#"{"ok":true,"data":{"services":[{"name":"demo-clerk"}]}}"#),
721            // destroy → remove_resource's registration pre-check (services list)
722            out(r#"{"ok":true,"data":{"services":[{"name":"demo-clerk"}]}}"#),
723            // destroy → the actual remove
724            out(r#"{"ok":true,"data":null}"#),
725        ]);
726        let stripe = StripeProjects::new(&runner, std::env::temp_dir());
727
728        assert_eq!(
729            observe(&stripe, &payload, "fallback").await.unwrap(),
730            IntegrationObservation::Present { drift: vec![] }
731        );
732        destroy(&stripe, &payload, "fallback").await.unwrap();
733        let calls = runner.calls();
734        assert!(
735            calls
736                .iter()
737                .any(|call| call.starts_with(&["remove".to_owned(), "demo-clerk".to_owned()]))
738        );
739    }
740
741    /// `apply` is a no-op when the checkpoint records `organizations = false`
742    /// (no Clerk API call, no Stripe call), and fails loudly on a corrupt
743    /// payload instead of silently skipping the toggle.
744    #[tokio::test]
745    async fn apply_skips_toggle_when_organizations_disabled() {
746        use crate::ProviderOps;
747
748        let payload = serde_json::to_string(&ClerkPayload {
749            stripe_resource: "demo-clerk".into(),
750            app_name: "atto-demo".into(),
751            credential_set: "development".into(),
752            organizations: false,
753            outputs: BTreeMap::new(),
754        })
755        .unwrap();
756        let provisioned = StepResource {
757            resource_kind: RESOURCE_KIND.into(),
758            resource_id: "demo-clerk".into(),
759            payload,
760        };
761        let runner = ScriptedRunner::new(vec![]);
762        let stripe = StripeProjects::new(&runner, std::env::temp_dir());
763        ClerkAuth
764            .apply(
765                &stripe.as_dyn(),
766                &test_def(),
767                "clerk",
768                "local",
769                &provisioned,
770            )
771            .await
772            .unwrap();
773        assert!(runner.calls().is_empty(), "apply must not touch Stripe");
774
775        let corrupt = StepResource {
776            resource_kind: RESOURCE_KIND.into(),
777            resource_id: "demo-clerk".into(),
778            payload: "not-json".into(),
779        };
780        let err = ClerkAuth
781            .apply(&stripe.as_dyn(), &test_def(), "clerk", "local", &corrupt)
782            .await
783            .unwrap_err();
784        assert!(err.to_string().contains("payload is invalid"));
785    }
786
787    #[tokio::test]
788    async fn enabling_clerk_organizations_patches_instance_settings() {
789        let server = MockServer::start().await;
790        Mock::given(method("PATCH"))
791            .and(path("/instance/organization_settings"))
792            .and(header("authorization", "Bearer sk_test_123"))
793            .and(body_json(serde_json::json!({
794                "enabled": true,
795                "slug_disabled": false,
796            })))
797            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
798                "object": "organization_settings",
799                "enabled": true
800            })))
801            .mount(&server)
802            .await;
803
804        update_clerk_organization_settings(&server.uri(), "sk_test_123", true, "demo-clerk")
805            .await
806            .unwrap();
807    }
808}