Skip to main content

greentic_deployer/cli/
messaging.rs

1//! `gtc op messaging endpoint {add,list,show,link-bundle,unlink-bundle,set-welcome-flow,remove}`
2//! (`Phase M1.2`).
3//!
4//! Manages `Environment.messaging_endpoints: Vec<MessagingEndpoint>` plus the
5//! derived `<env_dir>/messaging/` projection. Each mutation:
6//!
7//! 1. transacts under the env flock,
8//! 2. validates the resulting `Environment`,
9//! 3. saves `environment.json`,
10//! 4. re-materializes the per-endpoint files and `index.json`,
11//! 5. records the operation in the local-only audit log.
12//!
13//! Endpoint ids are ULID-shaped — minted on `add`, accepted as positionals on
14//! every other verb. `provider_id` is the INSTANCE identity (`teams-legal-bot`)
15//! distinct from `provider_type` (`teams`); the two together are unique per
16//! environment.
17
18use greentic_deploy_spec::{
19    BundleId, EnvId, MessagingEndpoint, MessagingEndpointId, PackId, SecretRef,
20};
21use serde::{Deserialize, Serialize};
22use serde_json::{Value, json};
23use std::str::FromStr;
24
25use crate::environment::{
26    AddMessagingEndpointPayload, EnvironmentReads, LocalFsStore, SetMessagingWelcomeFlowPayload,
27};
28
29use super::dispatch::{
30    MessagingEndpointAddArgs, MessagingEndpointLinkBundleArgs, MessagingEndpointRemoveArgs,
31    MessagingEndpointSetWelcomeFlowArgs,
32};
33use super::{
34    AuditCtx, OpError, OpFlags, OpOutcome, audit_and_record, map_store_err_preserving_noun,
35    resolve_idempotency_key,
36};
37use std::path::PathBuf;
38
39const NOUN: &str = "messaging.endpoint";
40
41// --- payloads ----------------------------------------------------------------
42
43#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct EndpointAddPayload {
45    pub environment_id: String,
46    pub provider_id: String,
47    pub provider_type: String,
48    pub display_name: String,
49    #[serde(default)]
50    pub secret_refs: Vec<String>,
51    /// Optional caller-supplied per-endpoint webhook secret ref. Only valid
52    /// for telegram-class providers. Required when adding such an endpoint
53    /// against a remote `--store-url` store (which never mints secrets); on
54    /// the local store it is optional (absent → the dev-store sink auto-mints).
55    #[serde(default, skip_serializing_if = "Option::is_none")]
56    pub webhook_secret_ref: Option<String>,
57    /// Caller-supplied A8 §2 idempotency key. Optional on the CLI
58    /// surface; when absent, the verb mints one per invocation.
59    #[serde(default, skip_serializing_if = "Option::is_none")]
60    pub idempotency_key: Option<String>,
61    pub updated_by: String,
62}
63
64#[derive(Debug, Clone, Serialize, Deserialize)]
65pub struct EndpointLinkBundlePayload {
66    pub environment_id: String,
67    pub endpoint_id: String,
68    pub bundle_id: String,
69    /// Caller-supplied A8 §2 idempotency key. Optional on the CLI
70    /// surface; when absent, the verb mints one per invocation.
71    #[serde(default, skip_serializing_if = "Option::is_none")]
72    pub idempotency_key: Option<String>,
73    pub updated_by: String,
74}
75
76#[derive(Debug, Clone, Serialize, Deserialize)]
77pub struct EndpointSetWelcomeFlowPayload {
78    pub environment_id: String,
79    pub endpoint_id: String,
80    pub bundle_id: String,
81    pub pack_id: String,
82    pub flow_id: String,
83    /// Caller-supplied A8 §2 idempotency key. Optional on the CLI
84    /// surface; when absent, the verb mints one per invocation.
85    #[serde(default, skip_serializing_if = "Option::is_none")]
86    pub idempotency_key: Option<String>,
87    pub updated_by: String,
88}
89
90#[derive(Debug, Clone, Serialize, Deserialize)]
91pub struct EndpointRotateWebhookSecretPayload {
92    pub environment_id: String,
93    pub endpoint_id: String,
94    /// Caller-supplied A8 §2 idempotency key. Optional on the CLI
95    /// surface; when absent, the verb mints one per invocation.
96    #[serde(default, skip_serializing_if = "Option::is_none")]
97    pub idempotency_key: Option<String>,
98    pub updated_by: String,
99    /// Optional NEW webhook secret ref (raw `secret://` URI) for the
100    /// new-ref rotation variant on a remote `--store-url` store: the operator
101    /// provisions the value in its own secrets plane and passes its ref here,
102    /// so the control-plane store records it without ever minting or custodying
103    /// secret material. Supplied via `--answers` only (the inline-flag form
104    /// carries no ref). Honored ONLY by the remote dispatch — the local store
105    /// mints its own value and REJECTS a supplied ref.
106    #[serde(default, skip_serializing_if = "Option::is_none")]
107    pub webhook_secret_ref: Option<String>,
108}
109
110#[derive(Debug, Clone, Serialize, Deserialize)]
111pub struct EndpointRemovePayload {
112    pub environment_id: String,
113    pub endpoint_id: String,
114    /// Caller-supplied A8 §2 idempotency key. Optional on the CLI
115    /// surface; when absent, the verb mints one per invocation.
116    #[serde(default, skip_serializing_if = "Option::is_none")]
117    pub idempotency_key: Option<String>,
118    pub updated_by: String,
119}
120
121// --- inline-flag → payload conversions ---------------------------------------
122//
123// Each verb's args struct yields:
124//   `Ok(None)`                — no inline flag was supplied → defer to `--answers`
125//   `Ok(Some(payload))`       — every required field present → use inline payload
126//   `Err(InvalidArgument(…))` — SOME flags supplied but required ones missing,
127//                                OR inline flags were combined with `--answers`,
128//                                which is always misuse
129//
130// This prevents a destructive-misdirection footgun where partial inline flags
131// silently fall through to `--answers`, loading a stale JSON that targets a
132// different resource.
133//
134// The mutual-exclusion check lives inside each converter (not at dispatch) so
135// every call-site gets it automatically — adding a new verb cannot forget the
136// guard.
137
138/// Reject `--answers` when any inline flag is set. Combining them is always a
139/// misuse: inline-only is fine, `--answers` only is fine; the mixed form would
140/// silently honor one and drop the other.
141fn reject_inline_plus_answers(
142    has_inline: bool,
143    flags: &OpFlags,
144    verb: &'static str,
145) -> Result<(), OpError> {
146    if has_inline && flags.answers.is_some() {
147        return Err(OpError::InvalidArgument(format!(
148            "messaging.endpoint {verb}: inline flags and --answers are mutually exclusive; use one or the other"
149        )));
150    }
151    Ok(())
152}
153
154/// Format a partial-flags rejection: which verb, the full required-flag list,
155/// and the subset that was actually missing.
156fn partial_inline_error(verb: &'static str, required: &str, missing: &[&str]) -> OpError {
157    OpError::InvalidArgument(format!(
158        "messaging.endpoint {verb}: inline-flag form requires {required}; missing: {}",
159        missing.join(", ")
160    ))
161}
162
163impl MessagingEndpointAddArgs {
164    /// Returns `true` when the caller supplied at least one inline flag.
165    fn has_inline_input(&self) -> bool {
166        self.env.is_some()
167            || self.provider_type.is_some()
168            || self.provider_id.is_some()
169            || self.display_name.is_some()
170            || !self.secret_ref.is_empty()
171            || self.webhook_secret_ref.is_some()
172            || self.idempotency_key.is_some()
173            || self.updated_by.is_some()
174    }
175
176    pub fn into_payload(
177        self,
178        verb: &'static str,
179        flags: &OpFlags,
180    ) -> Result<Option<EndpointAddPayload>, OpError> {
181        let has_inline = self.has_inline_input();
182        reject_inline_plus_answers(has_inline, flags, verb)?;
183        if !has_inline {
184            return Ok(None);
185        }
186        let mut missing = Vec::new();
187        if self.env.is_none() {
188            missing.push("--env");
189        }
190        if self.provider_type.is_none() {
191            missing.push("--provider-type");
192        }
193        if self.provider_id.is_none() {
194            missing.push("--provider-id");
195        }
196        if self.display_name.is_none() {
197            missing.push("--display-name");
198        }
199        if self.idempotency_key.is_none() {
200            missing.push("--idempotency-key");
201        }
202        if self.updated_by.is_none() {
203            missing.push("--updated-by");
204        }
205        if !missing.is_empty() {
206            return Err(partial_inline_error(
207                verb,
208                "--env, --provider-type, --provider-id, --display-name, --idempotency-key, --updated-by",
209                &missing,
210            ));
211        }
212        Ok(Some(EndpointAddPayload {
213            environment_id: self.env.unwrap(),
214            provider_id: self.provider_id.unwrap(),
215            provider_type: self.provider_type.unwrap(),
216            display_name: self.display_name.unwrap(),
217            secret_refs: self.secret_ref,
218            webhook_secret_ref: self.webhook_secret_ref,
219            idempotency_key: self.idempotency_key,
220            updated_by: self.updated_by.unwrap(),
221        }))
222    }
223}
224
225impl MessagingEndpointLinkBundleArgs {
226    /// Returns `true` when the caller supplied at least one inline flag.
227    fn has_inline_input(&self) -> bool {
228        self.env.is_some()
229            || self.endpoint_id.is_some()
230            || self.bundle_id.is_some()
231            || self.idempotency_key.is_some()
232            || self.updated_by.is_some()
233    }
234
235    pub fn into_payload(
236        self,
237        verb: &'static str,
238        flags: &OpFlags,
239    ) -> Result<Option<EndpointLinkBundlePayload>, OpError> {
240        let has_inline = self.has_inline_input();
241        reject_inline_plus_answers(has_inline, flags, verb)?;
242        if !has_inline {
243            return Ok(None);
244        }
245        let mut missing = Vec::new();
246        if self.env.is_none() {
247            missing.push("--env");
248        }
249        if self.endpoint_id.is_none() {
250            missing.push("--endpoint-id");
251        }
252        if self.bundle_id.is_none() {
253            missing.push("--bundle-id");
254        }
255        if self.idempotency_key.is_none() {
256            missing.push("--idempotency-key");
257        }
258        if self.updated_by.is_none() {
259            missing.push("--updated-by");
260        }
261        if !missing.is_empty() {
262            return Err(partial_inline_error(
263                verb,
264                "--env, --endpoint-id, --bundle-id, --idempotency-key, --updated-by",
265                &missing,
266            ));
267        }
268        Ok(Some(EndpointLinkBundlePayload {
269            environment_id: self.env.unwrap(),
270            endpoint_id: self.endpoint_id.unwrap(),
271            bundle_id: self.bundle_id.unwrap(),
272            idempotency_key: self.idempotency_key,
273            updated_by: self.updated_by.unwrap(),
274        }))
275    }
276}
277
278impl MessagingEndpointSetWelcomeFlowArgs {
279    /// Returns `true` when the caller supplied at least one inline flag.
280    fn has_inline_input(&self) -> bool {
281        self.env.is_some()
282            || self.endpoint_id.is_some()
283            || self.bundle_id.is_some()
284            || self.pack_id.is_some()
285            || self.flow_id.is_some()
286            || self.idempotency_key.is_some()
287            || self.updated_by.is_some()
288    }
289
290    pub fn into_payload(
291        self,
292        verb: &'static str,
293        flags: &OpFlags,
294    ) -> Result<Option<EndpointSetWelcomeFlowPayload>, OpError> {
295        let has_inline = self.has_inline_input();
296        reject_inline_plus_answers(has_inline, flags, verb)?;
297        if !has_inline {
298            return Ok(None);
299        }
300        let mut missing = Vec::new();
301        if self.env.is_none() {
302            missing.push("--env");
303        }
304        if self.endpoint_id.is_none() {
305            missing.push("--endpoint-id");
306        }
307        if self.bundle_id.is_none() {
308            missing.push("--bundle-id");
309        }
310        if self.pack_id.is_none() {
311            missing.push("--pack-id");
312        }
313        if self.flow_id.is_none() {
314            missing.push("--flow-id");
315        }
316        if self.idempotency_key.is_none() {
317            missing.push("--idempotency-key");
318        }
319        if self.updated_by.is_none() {
320            missing.push("--updated-by");
321        }
322        if !missing.is_empty() {
323            return Err(partial_inline_error(
324                verb,
325                "--env, --endpoint-id, --bundle-id, --pack-id, --flow-id, --idempotency-key, --updated-by",
326                &missing,
327            ));
328        }
329        Ok(Some(EndpointSetWelcomeFlowPayload {
330            environment_id: self.env.unwrap(),
331            endpoint_id: self.endpoint_id.unwrap(),
332            bundle_id: self.bundle_id.unwrap(),
333            pack_id: self.pack_id.unwrap(),
334            flow_id: self.flow_id.unwrap(),
335            idempotency_key: self.idempotency_key,
336            updated_by: self.updated_by.unwrap(),
337        }))
338    }
339}
340
341/// Validated 4-tuple shared by `remove` and `rotate-webhook-secret`: both verbs
342/// take the same args struct and consume the same field set.
343/// The third element is an optional idempotency key (minted by the CLI verb
344/// when absent).
345type ValidatedRemoveFields = (String, String, Option<String>, String);
346
347impl MessagingEndpointRemoveArgs {
348    /// Returns `true` when the caller supplied at least one inline flag.
349    fn has_inline_input(&self) -> bool {
350        self.env.is_some()
351            || self.endpoint_id.is_some()
352            || self.idempotency_key.is_some()
353            || self.updated_by.is_some()
354    }
355
356    /// Single validation pass shared by `remove` and `rotate-webhook-secret`.
357    /// Returns the four fields in canonical order; both verbs then assemble
358    /// their own payload type from this tuple.
359    fn validated_fields(
360        self,
361        verb: &'static str,
362        flags: &OpFlags,
363    ) -> Result<Option<ValidatedRemoveFields>, OpError> {
364        let has_inline = self.has_inline_input();
365        reject_inline_plus_answers(has_inline, flags, verb)?;
366        if !has_inline {
367            return Ok(None);
368        }
369        let mut missing = Vec::new();
370        if self.env.is_none() {
371            missing.push("--env");
372        }
373        if self.endpoint_id.is_none() {
374            missing.push("--endpoint-id");
375        }
376        if self.idempotency_key.is_none() {
377            missing.push("--idempotency-key");
378        }
379        if self.updated_by.is_none() {
380            missing.push("--updated-by");
381        }
382        if !missing.is_empty() {
383            return Err(partial_inline_error(
384                verb,
385                "--env, --endpoint-id, --idempotency-key, --updated-by",
386                &missing,
387            ));
388        }
389        Ok(Some((
390            self.env.unwrap(),
391            self.endpoint_id.unwrap(),
392            self.idempotency_key,
393            self.updated_by.unwrap(),
394        )))
395    }
396
397    pub fn into_remove_payload(
398        self,
399        verb: &'static str,
400        flags: &OpFlags,
401    ) -> Result<Option<EndpointRemovePayload>, OpError> {
402        Ok(self
403            .validated_fields(verb, flags)?
404            .map(|(env, eid, ikey, by)| EndpointRemovePayload {
405                environment_id: env,
406                endpoint_id: eid,
407                idempotency_key: ikey,
408                updated_by: by,
409            }))
410    }
411
412    pub fn into_rotate_payload(
413        self,
414        verb: &'static str,
415        flags: &OpFlags,
416    ) -> Result<Option<EndpointRotateWebhookSecretPayload>, OpError> {
417        Ok(self
418            .validated_fields(verb, flags)?
419            .map(|(env, eid, ikey, by)| EndpointRotateWebhookSecretPayload {
420                environment_id: env,
421                endpoint_id: eid,
422                idempotency_key: ikey,
423                updated_by: by,
424                // The inline-flag form carries no new ref; the new-ref variant
425                // is supplied via `--answers` (a remote-only enhancement).
426                webhook_secret_ref: None,
427            }))
428    }
429}
430
431// --- summary -----------------------------------------------------------------
432
433#[derive(Debug, Clone, Serialize, Deserialize)]
434pub struct EndpointSummary {
435    pub environment_id: String,
436    pub endpoint_id: String,
437    pub provider_type: String,
438    pub provider_id: String,
439    pub display_name: String,
440    pub linked_bundles: Vec<String>,
441    #[serde(skip_serializing_if = "Option::is_none")]
442    pub welcome_flow: Option<WelcomeFlowSummary>,
443    pub secret_refs: Vec<String>,
444    pub generation: u64,
445}
446
447#[derive(Debug, Clone, Serialize, Deserialize)]
448pub struct WelcomeFlowSummary {
449    pub bundle_id: String,
450    pub pack_id: String,
451    pub flow_id: String,
452}
453
454impl EndpointSummary {
455    pub(crate) fn from(env_id: &EnvId, ep: &MessagingEndpoint) -> Self {
456        Self {
457            environment_id: env_id.as_str().to_string(),
458            endpoint_id: ep.endpoint_id.to_string(),
459            provider_type: ep.provider_type.clone(),
460            provider_id: ep.provider_id.clone(),
461            display_name: ep.display_name.clone(),
462            linked_bundles: ep
463                .linked_bundles
464                .iter()
465                .map(|b| b.as_str().to_string())
466                .collect(),
467            welcome_flow: ep.welcome_flow.as_ref().map(|w| WelcomeFlowSummary {
468                bundle_id: w.bundle_id.as_str().to_string(),
469                pack_id: w.pack_id.as_str().to_string(),
470                flow_id: w.flow_id.clone(),
471            }),
472            secret_refs: ep
473                .secret_refs
474                .iter()
475                .map(|r| r.as_str().to_string())
476                .collect(),
477            generation: ep.generation,
478        }
479    }
480}
481
482// --- verbs -------------------------------------------------------------------
483
484pub fn add(
485    store: &LocalFsStore,
486    flags: &OpFlags,
487    payload: Option<EndpointAddPayload>,
488) -> Result<OpOutcome, OpError> {
489    if flags.schema_only {
490        return Ok(OpOutcome::new(NOUN, "add", add_schema()));
491    }
492    let payload = resolve_payload(flags, payload)?;
493    let env_id = parse_env_id(&payload.environment_id)?;
494    let provider_id = require_nonempty("provider_id", &payload.provider_id)?;
495    let provider_type = require_nonempty("provider_type", &payload.provider_type)?;
496    let display_name = require_nonempty("display_name", &payload.display_name)?;
497    let updated_by = require_nonempty("updated_by", &payload.updated_by)?;
498    let idempotency_key = resolve_idempotency_key(payload.idempotency_key)?;
499    let ctx = AuditCtx {
500        env_id: env_id.clone(),
501        noun: NOUN,
502        verb: "add",
503        target: json!({
504            "provider_id": provider_id,
505            "provider_type": provider_type,
506        }),
507        idempotency_key: Some(idempotency_key.as_str().to_string()),
508    };
509    audit_and_record(store, ctx, |committed| {
510        let ep = store
511            .add_messaging_endpoint(
512                &env_id,
513                AddMessagingEndpointPayload {
514                    provider_id,
515                    provider_type,
516                    display_name,
517                    secret_refs: payload.secret_refs,
518                    webhook_secret_ref: payload.webhook_secret_ref,
519                    updated_by,
520                },
521                idempotency_key,
522            )
523            .inspect_err(|err| {
524                if err.is_committed_after_save() {
525                    committed.mark_committed();
526                }
527            })
528            .map_err(map_store_err_preserving_noun)?;
529        committed.mark_committed();
530        let summary = EndpointSummary::from(&env_id, &ep);
531        Ok((
532            OpOutcome::new(
533                NOUN,
534                "add",
535                serde_json::to_value(summary).expect("EndpointSummary is json-safe"),
536            ),
537            super::AuditGens::NONE,
538        ))
539    })
540}
541
542pub fn list(
543    store: &dyn EnvironmentReads,
544    flags: &OpFlags,
545    env_id: &str,
546) -> Result<OpOutcome, OpError> {
547    if flags.schema_only {
548        return Ok(OpOutcome::new(
549            NOUN,
550            "list",
551            json!({"input_schema": "env_id positional"}),
552        ));
553    }
554    let env_id = parse_env_id(env_id)?;
555    if !store.env_exists(&env_id)? {
556        return Err(OpError::NotFound(format!("environment `{env_id}`")));
557    }
558    let env = store.load_env(&env_id)?;
559    let endpoints: Vec<EndpointSummary> = env
560        .messaging_endpoints
561        .iter()
562        .map(|e| EndpointSummary::from(&env_id, e))
563        .collect();
564    Ok(OpOutcome::new(
565        NOUN,
566        "list",
567        json!({"environment_id": env_id.as_str(), "endpoints": endpoints}),
568    ))
569}
570
571pub fn show(
572    store: &dyn EnvironmentReads,
573    flags: &OpFlags,
574    env_id: &str,
575    endpoint_id: &str,
576) -> Result<OpOutcome, OpError> {
577    if flags.schema_only {
578        return Ok(OpOutcome::new(
579            NOUN,
580            "show",
581            json!({"input_schema": "env_id + endpoint_id positionals"}),
582        ));
583    }
584    let env_id = parse_env_id(env_id)?;
585    let endpoint_id = parse_endpoint_id(endpoint_id)?;
586    if !store.env_exists(&env_id)? {
587        return Err(OpError::NotFound(format!("environment `{env_id}`")));
588    }
589    let env = store.load_env(&env_id)?;
590    let endpoint = env
591        .messaging_endpoints
592        .iter()
593        .find(|e| e.endpoint_id == endpoint_id)
594        .ok_or_else(|| {
595            OpError::NotFound(format!(
596                "messaging endpoint `{endpoint_id}` not found in env `{env_id}`"
597            ))
598        })?;
599    Ok(OpOutcome::new(
600        NOUN,
601        "show",
602        serde_json::to_value(EndpointSummary::from(&env_id, endpoint))
603            .expect("EndpointSummary is json-safe"),
604    ))
605}
606
607pub fn link_bundle(
608    store: &LocalFsStore,
609    flags: &OpFlags,
610    payload: Option<EndpointLinkBundlePayload>,
611) -> Result<OpOutcome, OpError> {
612    if flags.schema_only {
613        return Ok(OpOutcome::new(NOUN, "link-bundle", link_bundle_schema()));
614    }
615    let payload = resolve_payload(flags, payload)?;
616    let env_id = parse_env_id(&payload.environment_id)?;
617    let endpoint_id = parse_endpoint_id(&payload.endpoint_id)?;
618    let bundle_id = parse_bundle_id(&payload.bundle_id)?;
619    let updated_by = require_nonempty("updated_by", &payload.updated_by)?;
620    let idempotency_key = resolve_idempotency_key(payload.idempotency_key)?;
621    let ctx = AuditCtx {
622        env_id: env_id.clone(),
623        noun: NOUN,
624        verb: "link-bundle",
625        target: json!({
626            "endpoint_id": endpoint_id.to_string(),
627            "bundle_id": bundle_id.as_str(),
628        }),
629        idempotency_key: Some(idempotency_key.as_str().to_string()),
630    };
631    audit_and_record(store, ctx, |committed| {
632        let ep = store
633            .link_messaging_bundle(&env_id, endpoint_id, bundle_id, updated_by, idempotency_key)
634            .inspect_err(|err| {
635                if err.is_committed_after_save() {
636                    committed.mark_committed();
637                }
638            })
639            .map_err(map_store_err_preserving_noun)?;
640        committed.mark_committed();
641        let summary = EndpointSummary::from(&env_id, &ep);
642        Ok((
643            OpOutcome::new(
644                NOUN,
645                "link-bundle",
646                serde_json::to_value(summary).expect("EndpointSummary is json-safe"),
647            ),
648            super::AuditGens::NONE,
649        ))
650    })
651}
652
653pub fn unlink_bundle(
654    store: &LocalFsStore,
655    flags: &OpFlags,
656    payload: Option<EndpointLinkBundlePayload>,
657) -> Result<OpOutcome, OpError> {
658    if flags.schema_only {
659        return Ok(OpOutcome::new(NOUN, "unlink-bundle", link_bundle_schema()));
660    }
661    let payload = resolve_payload(flags, payload)?;
662    let env_id = parse_env_id(&payload.environment_id)?;
663    let endpoint_id = parse_endpoint_id(&payload.endpoint_id)?;
664    let bundle_id = parse_bundle_id(&payload.bundle_id)?;
665    let updated_by = require_nonempty("updated_by", &payload.updated_by)?;
666    let idempotency_key = resolve_idempotency_key(payload.idempotency_key)?;
667    let ctx = AuditCtx {
668        env_id: env_id.clone(),
669        noun: NOUN,
670        verb: "unlink-bundle",
671        target: json!({
672            "endpoint_id": endpoint_id.to_string(),
673            "bundle_id": bundle_id.as_str(),
674        }),
675        idempotency_key: Some(idempotency_key.as_str().to_string()),
676    };
677    audit_and_record(store, ctx, |committed| {
678        let ep = store
679            .unlink_messaging_bundle(&env_id, endpoint_id, bundle_id, updated_by, idempotency_key)
680            .inspect_err(|err| {
681                if err.is_committed_after_save() {
682                    committed.mark_committed();
683                }
684            })
685            .map_err(map_store_err_preserving_noun)?;
686        committed.mark_committed();
687        let summary = EndpointSummary::from(&env_id, &ep);
688        Ok((
689            OpOutcome::new(
690                NOUN,
691                "unlink-bundle",
692                serde_json::to_value(summary).expect("EndpointSummary is json-safe"),
693            ),
694            super::AuditGens::NONE,
695        ))
696    })
697}
698
699pub fn set_welcome_flow(
700    store: &LocalFsStore,
701    flags: &OpFlags,
702    payload: Option<EndpointSetWelcomeFlowPayload>,
703) -> Result<OpOutcome, OpError> {
704    if flags.schema_only {
705        return Ok(OpOutcome::new(
706            NOUN,
707            "set-welcome-flow",
708            set_welcome_flow_schema(),
709        ));
710    }
711    let payload = resolve_payload(flags, payload)?;
712    let env_id = parse_env_id(&payload.environment_id)?;
713    let endpoint_id = parse_endpoint_id(&payload.endpoint_id)?;
714    let bundle_id = parse_bundle_id(&payload.bundle_id)?;
715    let pack_id = require_nonempty("pack_id", &payload.pack_id)?;
716    let flow_id = require_nonempty("flow_id", &payload.flow_id)?;
717    let updated_by = require_nonempty("updated_by", &payload.updated_by)?;
718    let idempotency_key = resolve_idempotency_key(payload.idempotency_key)?;
719    let ctx = AuditCtx {
720        env_id: env_id.clone(),
721        noun: NOUN,
722        verb: "set-welcome-flow",
723        target: json!({
724            "endpoint_id": endpoint_id.to_string(),
725            "bundle_id": bundle_id.as_str(),
726            "pack_id": pack_id,
727            "flow_id": flow_id,
728        }),
729        idempotency_key: Some(idempotency_key.as_str().to_string()),
730    };
731    audit_and_record(store, ctx, |committed| {
732        let ep = store
733            .set_messaging_welcome_flow(
734                &env_id,
735                SetMessagingWelcomeFlowPayload {
736                    endpoint_id,
737                    bundle_id,
738                    pack_id: PackId::new(pack_id),
739                    flow_id,
740                    updated_by,
741                },
742                idempotency_key,
743            )
744            .inspect_err(|err| {
745                if err.is_committed_after_save() {
746                    committed.mark_committed();
747                }
748            })
749            .map_err(map_store_err_preserving_noun)?;
750        committed.mark_committed();
751        let summary = EndpointSummary::from(&env_id, &ep);
752        Ok((
753            OpOutcome::new(
754                NOUN,
755                "set-welcome-flow",
756                serde_json::to_value(summary).expect("EndpointSummary is json-safe"),
757            ),
758            super::AuditGens::NONE,
759        ))
760    })
761}
762
763pub fn remove(
764    store: &LocalFsStore,
765    flags: &OpFlags,
766    payload: Option<EndpointRemovePayload>,
767) -> Result<OpOutcome, OpError> {
768    if flags.schema_only {
769        return Ok(OpOutcome::new(NOUN, "remove", remove_schema()));
770    }
771    let payload = resolve_payload(flags, payload)?;
772    let env_id = parse_env_id(&payload.environment_id)?;
773    let endpoint_id = parse_endpoint_id(&payload.endpoint_id)?;
774    require_nonempty("updated_by", &payload.updated_by)?;
775    let idempotency_key = resolve_idempotency_key(payload.idempotency_key)?;
776    let ctx = AuditCtx {
777        env_id: env_id.clone(),
778        noun: NOUN,
779        verb: "remove",
780        target: json!({"endpoint_id": endpoint_id.to_string()}),
781        idempotency_key: Some(idempotency_key.as_str().to_string()),
782    };
783    audit_and_record(store, ctx, |committed| {
784        let removed_id = store
785            .remove_messaging_endpoint(&env_id, endpoint_id)
786            .inspect_err(|err| {
787                if err.is_committed_after_save() {
788                    committed.mark_committed();
789                }
790            })
791            .map_err(map_store_err_preserving_noun)?;
792        committed.mark_committed();
793        Ok((
794            OpOutcome::new(
795                NOUN,
796                "remove",
797                json!({"environment_id": env_id.as_str(), "endpoint_id": removed_id.to_string()}),
798            ),
799            super::AuditGens::NONE,
800        ))
801    })
802}
803
804pub fn rotate_webhook_secret(
805    store: &LocalFsStore,
806    flags: &OpFlags,
807    payload: Option<EndpointRotateWebhookSecretPayload>,
808) -> Result<OpOutcome, OpError> {
809    if flags.schema_only {
810        return Ok(OpOutcome::new(
811            NOUN,
812            "rotate-webhook-secret",
813            rotate_webhook_secret_schema(),
814        ));
815    }
816    let payload = resolve_payload(flags, payload)?;
817    // The new-ref variant is a remote-store-only enhancement: the local store
818    // custodies the value and mints its own ref, so a caller-supplied ref has
819    // no honest meaning here. Reject it rather than silently ignore it.
820    if payload.webhook_secret_ref.is_some() {
821        return Err(OpError::InvalidArgument(
822            "webhook_secret_ref is only honored by a remote `--store-url` store; the local \
823             store mints its own webhook secret value and ref"
824                .to_string(),
825        ));
826    }
827    let env_id = parse_env_id(&payload.environment_id)?;
828    let endpoint_id = parse_endpoint_id(&payload.endpoint_id)?;
829    let updated_by = require_nonempty("updated_by", &payload.updated_by)?;
830    let idempotency_key = resolve_idempotency_key(payload.idempotency_key)?;
831    let ctx = AuditCtx {
832        env_id: env_id.clone(),
833        noun: NOUN,
834        verb: "rotate-webhook-secret",
835        target: json!({"endpoint_id": endpoint_id.to_string()}),
836        idempotency_key: Some(idempotency_key.as_str().to_string()),
837    };
838    audit_and_record(store, ctx, |committed| {
839        let ep = store
840            .rotate_messaging_webhook_secret(&env_id, endpoint_id, updated_by, idempotency_key)
841            .inspect_err(|err| {
842                if err.is_committed_after_save() {
843                    committed.mark_committed();
844                }
845            })
846            .map_err(map_store_err_preserving_noun)?;
847        committed.mark_committed();
848        let summary = EndpointSummary::from(&env_id, &ep);
849        Ok((
850            OpOutcome::new(
851                NOUN,
852                "rotate-webhook-secret",
853                serde_json::to_value(summary).expect("EndpointSummary is json-safe"),
854            ),
855            super::AuditGens::NONE,
856        ))
857    })
858}
859
860// --- internals ---------------------------------------------------------------
861
862fn resolve_payload<T: serde::de::DeserializeOwned>(
863    flags: &OpFlags,
864    payload: Option<T>,
865) -> Result<T, OpError> {
866    if let Some(p) = payload {
867        return Ok(p);
868    }
869    if let Some(path) = &flags.answers {
870        return super::load_answers::<T>(path);
871    }
872    Err(OpError::InvalidArgument(
873        "no payload provided: pass --answers <path> or supply positional args".to_string(),
874    ))
875}
876
877fn parse_env_id(raw: &str) -> Result<EnvId, OpError> {
878    EnvId::try_from(raw).map_err(|e| OpError::InvalidArgument(format!("environment_id: {e}")))
879}
880
881fn parse_endpoint_id(raw: &str) -> Result<MessagingEndpointId, OpError> {
882    ulid::Ulid::from_str(raw)
883        .map(MessagingEndpointId)
884        .map_err(|e| OpError::InvalidArgument(format!("endpoint_id: {e}")))
885}
886
887fn parse_bundle_id(raw: &str) -> Result<BundleId, OpError> {
888    if raw.trim().is_empty() {
889        return Err(OpError::InvalidArgument(
890            "bundle_id must not be empty".to_string(),
891        ));
892    }
893    Ok(BundleId::new(raw))
894}
895
896fn require_nonempty(field: &str, value: &str) -> Result<String, OpError> {
897    if value.trim().is_empty() {
898        return Err(OpError::InvalidArgument(format!(
899            "{field} must not be empty"
900        )));
901    }
902    Ok(value.to_string())
903}
904
905// The idem-writer stamp helpers (`format_idem_writer`, `idem_suffix`,
906// `carries_idem_key`, `stamp_mutation`) and the telegram-class classifier
907// moved to `greentic_deploy_spec::engine::messaging` (PR-4.2h) so the
908// operator-store-server applies identical replay/stamping semantics. What
909// stays here is the LocalFS webhook-secret SINK: CSPRNG value generation +
910// the dev-store write below.
911
912/// The generation policy for a per-endpoint webhook secret: 32 `raw_text`
913/// characters. Routed through the shared `greentic-secrets` generator so the
914/// deployer mints webhook material the same way every other consumer mints a
915/// pack-declared generated secret (instead of a local ad-hoc CSPRNG).
916fn webhook_secret_policy() -> greentic_secrets_lib::GeneratedSecretRequirement {
917    greentic_secrets_lib::GeneratedSecretRequirement {
918        policy: "random".to_string(),
919        length: 32,
920        encoding: "raw_text".to_string(),
921        scope: greentic_secrets_lib::GeneratedSecretScope {
922            level: "tenant".to_string(),
923            team: Some("_".to_string()),
924        },
925        regenerate_if_present: false,
926    }
927}
928
929/// Mint a fresh per-endpoint webhook secret via the shared generator.
930fn generate_webhook_secret() -> Result<String, OpError> {
931    let (bytes, _) = greentic_secrets_lib::generate_secret_value(&webhook_secret_policy())
932        .map_err(|e| OpError::InvalidArgument(format!("webhook secret generation: {e}")))?;
933    String::from_utf8(bytes)
934        .map_err(|e| OpError::InvalidArgument(format!("webhook secret encoding: {e}")))
935}
936
937/// Construct the deterministic `SecretRef` URI for an endpoint's webhook
938/// secret. The dev-store backend requires the 5-segment shape
939/// `secrets://<env>/<tenant>/<team>/<pack>/<name>`, so the SecretRef mirrors
940/// that: `secret://<env>/<tenant>/_/messaging-<eid>/webhook_secret`.
941///
942/// `tenant` is the env's owning tenant (`tenant_org_id`), or `default` for an
943/// ownerless local env. It MUST match the tenant the runtime secrets backend
944/// is scoped to: a Vault-backed worker pod scopes its `SecretsCore` to the env
945/// owner, so a hardcoded `default` segment on an owned env would be refused
946/// in-process and the webhook would never resolve. `_` is the tenant-level
947/// team placeholder (the runtime's `canonical_team` maps `default` → `_`). The
948/// endpoint id is folded to lowercase in the pack segment because
949/// `MessagingEndpointId` is a ULID (uppercase) and the runtime canonicalizes
950/// pack segments.
951fn build_webhook_secret_ref(
952    env_id: &EnvId,
953    endpoint_id: &MessagingEndpointId,
954    tenant: &str,
955) -> Result<SecretRef, OpError> {
956    let eid_lower = endpoint_id.to_string().to_lowercase();
957    let uri = format!(
958        "secret://{}/{}/_/messaging-{}/webhook_secret",
959        env_id.as_str(),
960        tenant,
961        eid_lower
962    );
963    SecretRef::try_new(uri)
964        .map_err(|e| OpError::InvalidArgument(format!("webhook secret ref: {e}")))
965}
966
967/// Provision a webhook secret for an endpoint: choose the ref URI (existing if
968/// rotating an endpoint that already has one; freshly built under the resolved
969/// tenant otherwise) and, when the env's secrets backend custodies values in the
970/// local dev-store (`custodial`), mint a fresh value and write it there. For a
971/// non-custodial backend (e.g. Vault) the value is seeded out-of-band by the
972/// operator — the control plane only stamps the ref and never writes a value the
973/// runtime would not read. Returns the ref the caller stamps onto
974/// `MessagingEndpoint.webhook_secret_ref`.
975///
976/// `owner` is the env's owning tenant. The dev-store is not tenant-scoped, so a
977/// custodial env with no owner keeps the conventional `default` segment. A
978/// non-custodial backend scopes reads to the env owner, so a `default`/blank
979/// segment would be unresolvable at runtime — require a real owner and fail
980/// closed rather than stamp a dead ref.
981///
982/// Shared by `add` (always `existing_ref = None` — fresh endpoint) and
983/// `rotate_webhook_secret` (`existing_ref = Some(_)` for endpoints that
984/// already carry a ref, `None` for first-time setting on a pre-decoupling
985/// endpoint). Keeps the entropy source, URI shape, and write target in one
986/// place so the two verbs cannot drift.
987pub(crate) fn provision_webhook_secret(
988    store: &LocalFsStore,
989    env_id: &EnvId,
990    endpoint_id: &MessagingEndpointId,
991    owner: Option<&str>,
992    custodial: bool,
993    existing_ref: Option<&SecretRef>,
994) -> Result<SecretRef, OpError> {
995    // The tenant segment is the env owner when present. When it is absent, the
996    // dev-store (not tenant-scoped) falls back to the conventional `default`,
997    // while a non-custodial backend (tenant-scoped) fails closed rather than
998    // stamp an unresolvable ref.
999    let tenant = match owner {
1000        Some(owner) => owner,
1001        None if custodial => "default",
1002        None => {
1003            return Err(OpError::Conflict(format!(
1004                "a webhook secret on a non-dev-store secrets backend requires the environment to \
1005                 declare an owning tenant (`tenant_org_id`); env `{}` has none",
1006                env_id.as_str()
1007            )));
1008        }
1009    };
1010    let secret_ref = match existing_ref {
1011        Some(r) => r.clone(),
1012        None => build_webhook_secret_ref(env_id, endpoint_id, tenant)?,
1013    };
1014    if custodial {
1015        let value = generate_webhook_secret()?;
1016        write_webhook_secret_to_devstore(store, env_id, &secret_ref, &value)?;
1017    }
1018    Ok(secret_ref)
1019}
1020
1021/// Write the webhook secret VALUE into the env-pack dev-store. Mirrors the
1022/// `cli/secrets.rs put` idiom: same `dev_store_put` + `resolve_dev_store_path`
1023/// pattern (sidecar flock, dedicated OS thread, own current-thread runtime).
1024fn write_webhook_secret_to_devstore(
1025    store: &LocalFsStore,
1026    env_id: &EnvId,
1027    secret_ref: &SecretRef,
1028    value: &str,
1029) -> Result<(), OpError> {
1030    let env_dir = store.env_dir(env_id)?;
1031    let dev_path = super::secrets::resolve_dev_store_path(
1032        &env_dir,
1033        std::env::var_os(super::secrets::DEV_SECRETS_PATH_ENV).map(PathBuf::from),
1034    );
1035    let store_uri = super::secrets::secret_ref_to_store_uri(secret_ref)?;
1036    super::secrets::dev_store_put(&dev_path, &store_uri, value)
1037}
1038
1039// --- schema stubs ------------------------------------------------------------
1040//
1041// JSON Schema generation for the payload types is left out of M1.2 — the
1042// schemars wiring in `crates/greentic-deploy-spec/src/json_schema.rs` is a
1043// reserved stub for the whole crate, and adding hand-written schemas here
1044// would drift from the rest of the cli/ modules. Stubs return a hint string
1045// today; the wiring lands when the workspace schemars pass does.
1046
1047fn verb_schema(verb: &str, fields: &[&str]) -> Value {
1048    json!({ "noun": NOUN, "verb": verb, "fields": fields })
1049}
1050
1051fn add_schema() -> Value {
1052    verb_schema(
1053        "add",
1054        &[
1055            "environment_id",
1056            "provider_id",
1057            "provider_type",
1058            "display_name",
1059            "secret_refs (array of secret:// URIs)",
1060            "webhook_secret_ref (optional; telegram-class only; required on a remote --store-url store)",
1061            "idempotency_key",
1062            "updated_by",
1063        ],
1064    )
1065}
1066
1067fn link_bundle_schema() -> Value {
1068    verb_schema(
1069        "link-bundle / unlink-bundle",
1070        &[
1071            "environment_id",
1072            "endpoint_id",
1073            "bundle_id",
1074            "idempotency_key",
1075            "updated_by",
1076        ],
1077    )
1078}
1079
1080fn set_welcome_flow_schema() -> Value {
1081    verb_schema(
1082        "set-welcome-flow",
1083        &[
1084            "environment_id",
1085            "endpoint_id",
1086            "bundle_id",
1087            "pack_id",
1088            "flow_id",
1089            "idempotency_key",
1090            "updated_by",
1091        ],
1092    )
1093}
1094
1095fn remove_schema() -> Value {
1096    verb_schema(
1097        "remove",
1098        &[
1099            "environment_id",
1100            "endpoint_id",
1101            "idempotency_key",
1102            "updated_by",
1103        ],
1104    )
1105}
1106
1107fn rotate_webhook_secret_schema() -> Value {
1108    verb_schema(
1109        "rotate-webhook-secret",
1110        &[
1111            "environment_id",
1112            "endpoint_id",
1113            "idempotency_key",
1114            "updated_by",
1115        ],
1116    )
1117}
1118
1119#[cfg(test)]
1120mod tests {
1121    use super::*;
1122    use crate::cli::tests_common::{make_bundle_deployment, make_env};
1123    use crate::environment::EnvironmentStore;
1124    use crate::environment::messaging::MessagingEndpointIndexEntry;
1125    use tempfile::tempdir;
1126
1127    fn seeded_store_with_bundles(
1128        bundle_ids: &[&str],
1129    ) -> (tempfile::TempDir, LocalFsStore, Vec<BundleId>) {
1130        let dir = tempdir().unwrap();
1131        let store = LocalFsStore::new(dir.path());
1132        let mut env = make_env("local");
1133        let mut ids = Vec::new();
1134        for id in bundle_ids {
1135            let b = make_bundle_deployment("local", id);
1136            ids.push(b.bundle_id.clone());
1137            env.bundles.push(b);
1138        }
1139        store.save(&env).unwrap();
1140        (dir, store, ids)
1141    }
1142
1143    fn add_payload(provider_type: &str, provider_id: &str, key: &str) -> EndpointAddPayload {
1144        EndpointAddPayload {
1145            environment_id: "local".to_string(),
1146            provider_id: provider_id.to_string(),
1147            provider_type: provider_type.to_string(),
1148            display_name: format!("{provider_type} {provider_id}"),
1149            secret_refs: vec![],
1150            webhook_secret_ref: None,
1151            idempotency_key: Some(key.to_string()),
1152            updated_by: "tester".to_string(),
1153        }
1154    }
1155
1156    fn endpoint_id_from(outcome: &OpOutcome) -> MessagingEndpointId {
1157        let raw = outcome.result["endpoint_id"].as_str().expect("endpoint_id");
1158        parse_endpoint_id(raw).expect("endpoint_id parses")
1159    }
1160
1161    #[test]
1162    fn add_then_show_returns_endpoint() {
1163        let (_dir, store, _) = seeded_store_with_bundles(&[]);
1164        let added = add(
1165            &store,
1166            &OpFlags::default(),
1167            Some(add_payload("teams", "legal-bot", "k1")),
1168        )
1169        .unwrap();
1170        let id = endpoint_id_from(&added);
1171        let shown = show(&store, &OpFlags::default(), "local", &id.to_string()).unwrap();
1172        assert_eq!(shown.result["provider_type"], "teams");
1173        assert_eq!(shown.result["provider_id"], "legal-bot");
1174    }
1175
1176    #[test]
1177    fn list_enumerates_all_endpoints() {
1178        let (_dir, store, _) = seeded_store_with_bundles(&[]);
1179        add(
1180            &store,
1181            &OpFlags::default(),
1182            Some(add_payload("teams", "legal", "k1")),
1183        )
1184        .unwrap();
1185        add(
1186            &store,
1187            &OpFlags::default(),
1188            Some(add_payload("teams", "accounting", "k2")),
1189        )
1190        .unwrap();
1191        let listed = list(&store, &OpFlags::default(), "local").unwrap();
1192        let endpoints = listed.result["endpoints"].as_array().unwrap();
1193        assert_eq!(endpoints.len(), 2);
1194    }
1195
1196    #[test]
1197    fn duplicate_provider_instance_rejected_at_verb_layer() {
1198        let (_dir, store, _) = seeded_store_with_bundles(&[]);
1199        add(
1200            &store,
1201            &OpFlags::default(),
1202            Some(add_payload("teams", "legal", "k1")),
1203        )
1204        .unwrap();
1205        let err = add(
1206            &store,
1207            &OpFlags::default(),
1208            Some(add_payload("teams", "legal", "k2")),
1209        )
1210        .unwrap_err();
1211        assert!(matches!(err, OpError::Conflict(_)));
1212    }
1213
1214    #[test]
1215    fn idempotent_add_returns_same_endpoint() {
1216        let (_dir, store, _) = seeded_store_with_bundles(&[]);
1217        let first = add(
1218            &store,
1219            &OpFlags::default(),
1220            Some(add_payload("teams", "legal", "k-replay")),
1221        )
1222        .unwrap();
1223        let second = add(
1224            &store,
1225            &OpFlags::default(),
1226            Some(add_payload("teams", "legal", "k-replay")),
1227        )
1228        .unwrap();
1229        assert_eq!(
1230            endpoint_id_from(&first),
1231            endpoint_id_from(&second),
1232            "idempotent replay must return the original endpoint"
1233        );
1234    }
1235
1236    #[test]
1237    fn same_key_with_different_payload_is_conflict() {
1238        let (_dir, store, _) = seeded_store_with_bundles(&[]);
1239        add(
1240            &store,
1241            &OpFlags::default(),
1242            Some(add_payload("teams", "legal", "k-shared")),
1243        )
1244        .unwrap();
1245        let err = add(
1246            &store,
1247            &OpFlags::default(),
1248            Some(add_payload("slack", "ops", "k-shared")),
1249        )
1250        .unwrap_err();
1251        assert!(matches!(err, OpError::Conflict(_)));
1252    }
1253
1254    #[test]
1255    fn link_then_unlink_bundle_updates_endpoint() {
1256        let (_dir, store, bundle_ids) = seeded_store_with_bundles(&["legal-pack"]);
1257        let bundle = &bundle_ids[0];
1258        let added = add(
1259            &store,
1260            &OpFlags::default(),
1261            Some(add_payload("teams", "legal", "k1")),
1262        )
1263        .unwrap();
1264        let id = endpoint_id_from(&added);
1265        let linked = link_bundle(
1266            &store,
1267            &OpFlags::default(),
1268            Some(EndpointLinkBundlePayload {
1269                environment_id: "local".to_string(),
1270                endpoint_id: id.to_string(),
1271                bundle_id: bundle.as_str().to_string(),
1272                idempotency_key: Some("k2".to_string()),
1273                updated_by: "tester".to_string(),
1274            }),
1275        )
1276        .unwrap();
1277        let linked_bundles = linked.result["linked_bundles"].as_array().unwrap();
1278        assert_eq!(linked_bundles.len(), 1);
1279        let unlinked = unlink_bundle(
1280            &store,
1281            &OpFlags::default(),
1282            Some(EndpointLinkBundlePayload {
1283                environment_id: "local".to_string(),
1284                endpoint_id: id.to_string(),
1285                bundle_id: bundle.as_str().to_string(),
1286                idempotency_key: Some("k3".to_string()),
1287                updated_by: "tester".to_string(),
1288            }),
1289        )
1290        .unwrap();
1291        assert!(
1292            unlinked.result["linked_bundles"]
1293                .as_array()
1294                .unwrap()
1295                .is_empty()
1296        );
1297    }
1298
1299    #[test]
1300    fn link_unknown_bundle_is_not_found() {
1301        let (_dir, store, _) = seeded_store_with_bundles(&[]);
1302        let added = add(
1303            &store,
1304            &OpFlags::default(),
1305            Some(add_payload("teams", "legal", "k1")),
1306        )
1307        .unwrap();
1308        let id = endpoint_id_from(&added);
1309        let err = link_bundle(
1310            &store,
1311            &OpFlags::default(),
1312            Some(EndpointLinkBundlePayload {
1313                environment_id: "local".to_string(),
1314                endpoint_id: id.to_string(),
1315                bundle_id: "ghost-bundle".to_string(),
1316                idempotency_key: Some("k2".to_string()),
1317                updated_by: "tester".to_string(),
1318            }),
1319        )
1320        .unwrap_err();
1321        assert!(matches!(err, OpError::NotFound(_)));
1322    }
1323
1324    #[test]
1325    fn set_welcome_flow_requires_linked_bundle() {
1326        let (_dir, store, bundle_ids) = seeded_store_with_bundles(&["legal-pack"]);
1327        let bundle = &bundle_ids[0];
1328        let added = add(
1329            &store,
1330            &OpFlags::default(),
1331            Some(add_payload("teams", "legal", "k1")),
1332        )
1333        .unwrap();
1334        let id = endpoint_id_from(&added);
1335        // No link yet ⇒ set-welcome-flow rejects with InvalidArgument.
1336        let err = set_welcome_flow(
1337            &store,
1338            &OpFlags::default(),
1339            Some(EndpointSetWelcomeFlowPayload {
1340                environment_id: "local".to_string(),
1341                endpoint_id: id.to_string(),
1342                bundle_id: bundle.as_str().to_string(),
1343                pack_id: "legal".to_string(),
1344                flow_id: "main".to_string(),
1345                idempotency_key: Some("k2".to_string()),
1346                updated_by: "tester".to_string(),
1347            }),
1348        )
1349        .unwrap_err();
1350        assert!(matches!(err, OpError::InvalidArgument(_)));
1351        // Link, then succeed.
1352        link_bundle(
1353            &store,
1354            &OpFlags::default(),
1355            Some(EndpointLinkBundlePayload {
1356                environment_id: "local".to_string(),
1357                endpoint_id: id.to_string(),
1358                bundle_id: bundle.as_str().to_string(),
1359                idempotency_key: Some("k3".to_string()),
1360                updated_by: "tester".to_string(),
1361            }),
1362        )
1363        .unwrap();
1364        let set = set_welcome_flow(
1365            &store,
1366            &OpFlags::default(),
1367            Some(EndpointSetWelcomeFlowPayload {
1368                environment_id: "local".to_string(),
1369                endpoint_id: id.to_string(),
1370                bundle_id: bundle.as_str().to_string(),
1371                pack_id: "legal".to_string(),
1372                flow_id: "main".to_string(),
1373                idempotency_key: Some("k4".to_string()),
1374                updated_by: "tester".to_string(),
1375            }),
1376        )
1377        .unwrap();
1378        assert_eq!(set.result["welcome_flow"]["flow_id"], "main");
1379    }
1380
1381    #[test]
1382    fn unlink_welcome_flow_bundle_is_rejected() {
1383        let (_dir, store, bundle_ids) = seeded_store_with_bundles(&["legal-pack"]);
1384        let bundle = &bundle_ids[0];
1385        let added = add(
1386            &store,
1387            &OpFlags::default(),
1388            Some(add_payload("teams", "legal", "k1")),
1389        )
1390        .unwrap();
1391        let id = endpoint_id_from(&added);
1392        link_bundle(
1393            &store,
1394            &OpFlags::default(),
1395            Some(EndpointLinkBundlePayload {
1396                environment_id: "local".to_string(),
1397                endpoint_id: id.to_string(),
1398                bundle_id: bundle.as_str().to_string(),
1399                idempotency_key: Some("k2".to_string()),
1400                updated_by: "tester".to_string(),
1401            }),
1402        )
1403        .unwrap();
1404        set_welcome_flow(
1405            &store,
1406            &OpFlags::default(),
1407            Some(EndpointSetWelcomeFlowPayload {
1408                environment_id: "local".to_string(),
1409                endpoint_id: id.to_string(),
1410                bundle_id: bundle.as_str().to_string(),
1411                pack_id: "legal".to_string(),
1412                flow_id: "main".to_string(),
1413                idempotency_key: Some("k3".to_string()),
1414                updated_by: "tester".to_string(),
1415            }),
1416        )
1417        .unwrap();
1418        let err = unlink_bundle(
1419            &store,
1420            &OpFlags::default(),
1421            Some(EndpointLinkBundlePayload {
1422                environment_id: "local".to_string(),
1423                endpoint_id: id.to_string(),
1424                bundle_id: bundle.as_str().to_string(),
1425                idempotency_key: Some("k4".to_string()),
1426                updated_by: "tester".to_string(),
1427            }),
1428        )
1429        .unwrap_err();
1430        assert!(matches!(err, OpError::Conflict(_)));
1431    }
1432
1433    #[test]
1434    fn remove_then_show_returns_not_found() {
1435        let (_dir, store, _) = seeded_store_with_bundles(&[]);
1436        let added = add(
1437            &store,
1438            &OpFlags::default(),
1439            Some(add_payload("teams", "legal", "k1")),
1440        )
1441        .unwrap();
1442        let id = endpoint_id_from(&added);
1443        remove(
1444            &store,
1445            &OpFlags::default(),
1446            Some(EndpointRemovePayload {
1447                environment_id: "local".to_string(),
1448                endpoint_id: id.to_string(),
1449                idempotency_key: Some("k2".to_string()),
1450                updated_by: "tester".to_string(),
1451            }),
1452        )
1453        .unwrap();
1454        let err = show(&store, &OpFlags::default(), "local", &id.to_string()).unwrap_err();
1455        assert!(matches!(err, OpError::NotFound(_)));
1456    }
1457
1458    #[test]
1459    fn idempotent_remove_succeeds_when_absent() {
1460        let (_dir, store, _) = seeded_store_with_bundles(&[]);
1461        let outcome = remove(
1462            &store,
1463            &OpFlags::default(),
1464            Some(EndpointRemovePayload {
1465                environment_id: "local".to_string(),
1466                endpoint_id: MessagingEndpointId::new().to_string(),
1467                idempotency_key: Some("k1".to_string()),
1468                updated_by: "tester".to_string(),
1469            }),
1470        )
1471        .unwrap();
1472        assert_eq!(outcome.op, "remove");
1473    }
1474
1475    #[test]
1476    fn add_materializes_index_and_per_endpoint_file() {
1477        let (_dir, store, _) = seeded_store_with_bundles(&[]);
1478        let added = add(
1479            &store,
1480            &OpFlags::default(),
1481            Some(add_payload("teams", "legal", "k1")),
1482        )
1483        .unwrap();
1484        let id = endpoint_id_from(&added);
1485        let env_dir = store.env_dir(&EnvId::try_from("local").unwrap()).unwrap();
1486        let endpoint_path = env_dir.join("messaging").join(format!("{id}.json"));
1487        let index_path = env_dir.join("messaging").join("index.json");
1488        assert!(
1489            endpoint_path.exists(),
1490            "per-endpoint file must be materialized"
1491        );
1492        assert!(index_path.exists(), "index.json must be materialized");
1493        let index: Vec<MessagingEndpointIndexEntry> =
1494            serde_json::from_slice(&std::fs::read(&index_path).unwrap()).unwrap();
1495        assert_eq!(index.len(), 1);
1496        assert_eq!(index[0].endpoint_id, id);
1497    }
1498
1499    #[test]
1500    fn remove_prunes_per_endpoint_file_and_deletes_empty_index() {
1501        let (_dir, store, _) = seeded_store_with_bundles(&[]);
1502        let added = add(
1503            &store,
1504            &OpFlags::default(),
1505            Some(add_payload("teams", "legal", "k1")),
1506        )
1507        .unwrap();
1508        let id = endpoint_id_from(&added);
1509        let env_dir = store.env_dir(&EnvId::try_from("local").unwrap()).unwrap();
1510        let endpoint_path = env_dir.join("messaging").join(format!("{id}.json"));
1511        let index_path = env_dir.join("messaging").join("index.json");
1512        assert!(endpoint_path.exists());
1513        remove(
1514            &store,
1515            &OpFlags::default(),
1516            Some(EndpointRemovePayload {
1517                environment_id: "local".to_string(),
1518                endpoint_id: id.to_string(),
1519                idempotency_key: Some("k2".to_string()),
1520                updated_by: "tester".to_string(),
1521            }),
1522        )
1523        .unwrap();
1524        assert!(
1525            !endpoint_path.exists(),
1526            "per-endpoint file must be pruned on remove"
1527        );
1528        assert!(
1529            !index_path.exists(),
1530            "index.json must be deleted when env has no endpoints"
1531        );
1532    }
1533
1534    #[test]
1535    fn idempotent_replay_repairs_stale_projection() {
1536        // Regression for Codex Finding #2: a prior failed call may have
1537        // saved env.json but left the projection stale; the idempotent
1538        // retry must re-materialize the projection rather than early-return
1539        // without touching disk.
1540        let (_dir, store, _) = seeded_store_with_bundles(&[]);
1541        let added = add(
1542            &store,
1543            &OpFlags::default(),
1544            Some(add_payload("teams", "legal", "k1")),
1545        )
1546        .unwrap();
1547        let id = endpoint_id_from(&added);
1548        let env_dir = store.env_dir(&EnvId::try_from("local").unwrap()).unwrap();
1549        let endpoint_path = env_dir.join("messaging").join(format!("{id}.json"));
1550        let index_path = env_dir.join("messaging").join("index.json");
1551        // Simulate a prior failed projection refresh by clobbering the
1552        // per-endpoint file out from under the store.
1553        std::fs::remove_file(&endpoint_path).unwrap();
1554        std::fs::remove_file(&index_path).unwrap();
1555        // Replay add with the same key — should re-publish the projection
1556        // even though env.json is already correct.
1557        add(
1558            &store,
1559            &OpFlags::default(),
1560            Some(add_payload("teams", "legal", "k1")),
1561        )
1562        .unwrap();
1563        assert!(
1564            endpoint_path.exists(),
1565            "idempotent add replay must republish the per-endpoint projection"
1566        );
1567        assert!(
1568            index_path.exists(),
1569            "idempotent add replay must republish the index"
1570        );
1571    }
1572
1573    #[test]
1574    fn idempotent_remove_replay_repairs_stale_projection() {
1575        // Companion to the add case: remove that succeeded against env.json
1576        // but failed to prune the projection must be repaired on retry,
1577        // which hits the absent-endpoint idempotent path.
1578        let (_dir, store, _) = seeded_store_with_bundles(&[]);
1579        let added = add(
1580            &store,
1581            &OpFlags::default(),
1582            Some(add_payload("teams", "legal", "k1")),
1583        )
1584        .unwrap();
1585        let id = endpoint_id_from(&added);
1586        let env_dir = store.env_dir(&EnvId::try_from("local").unwrap()).unwrap();
1587        let endpoint_path = env_dir.join("messaging").join(format!("{id}.json"));
1588        // Manually mutate env.json out from under the store to drop the
1589        // endpoint, leaving the projection in the "previous-call-saved-but-
1590        // pruning-failed" shape.
1591        let env_path = env_dir.join("environment.json");
1592        let mut env: greentic_deploy_spec::Environment =
1593            serde_json::from_slice(&std::fs::read(&env_path).unwrap()).unwrap();
1594        env.messaging_endpoints.clear();
1595        std::fs::write(&env_path, serde_json::to_vec_pretty(&env).unwrap()).unwrap();
1596        assert!(
1597            endpoint_path.exists(),
1598            "fixture: per-endpoint file must still be present pre-replay"
1599        );
1600        // Replay remove — endpoint is absent from env.json, hits the
1601        // idempotent path, must still prune the stale per-endpoint file.
1602        remove(
1603            &store,
1604            &OpFlags::default(),
1605            Some(EndpointRemovePayload {
1606                environment_id: "local".to_string(),
1607                endpoint_id: id.to_string(),
1608                idempotency_key: Some("k2".to_string()),
1609                updated_by: "tester".to_string(),
1610            }),
1611        )
1612        .unwrap();
1613        assert!(
1614            !endpoint_path.exists(),
1615            "idempotent remove replay must prune the stale per-endpoint file"
1616        );
1617    }
1618
1619    #[test]
1620    fn set_welcome_flow_rejects_unknown_pack_id_when_revisions_exist() {
1621        // Regression for Codex Finding #3: when the linked bundle has at
1622        // least one current revision with a non-empty pack_list, a typo'd
1623        // pack_id is rejected at write time rather than persisted as a
1624        // ticking time-bomb for first-contact dispatch (M1.5).
1625        use greentic_deploy_spec::RevisionLifecycle;
1626        let (_dir, store, bundle_ids) = seeded_store_with_bundles(&["legal-pack"]);
1627        let bundle = &bundle_ids[0];
1628        // Seed a revision with a known pack id so the validator has
1629        // something to compare against.
1630        let env_id_typed = EnvId::try_from("local").unwrap();
1631        let mut env = store.load(&env_id_typed).unwrap();
1632        let deployment_id = env.bundles[0].deployment_id;
1633        let rev = crate::cli::tests_common::make_revision(
1634            "local",
1635            bundle.as_str(),
1636            &deployment_id,
1637            1,
1638            RevisionLifecycle::Ready,
1639        );
1640        env.bundles[0].current_revisions.push(rev.revision_id);
1641        env.revisions.push(rev);
1642        store.save(&env).unwrap();
1643        // Wire endpoint + link.
1644        let added = add(
1645            &store,
1646            &OpFlags::default(),
1647            Some(add_payload("teams", "legal", "k1")),
1648        )
1649        .unwrap();
1650        let id = endpoint_id_from(&added);
1651        link_bundle(
1652            &store,
1653            &OpFlags::default(),
1654            Some(EndpointLinkBundlePayload {
1655                environment_id: "local".to_string(),
1656                endpoint_id: id.to_string(),
1657                bundle_id: bundle.as_str().to_string(),
1658                idempotency_key: Some("k2".to_string()),
1659                updated_by: "tester".to_string(),
1660            }),
1661        )
1662        .unwrap();
1663        // Typo'd pack id — must reject.
1664        let err = set_welcome_flow(
1665            &store,
1666            &OpFlags::default(),
1667            Some(EndpointSetWelcomeFlowPayload {
1668                environment_id: "local".to_string(),
1669                endpoint_id: id.to_string(),
1670                bundle_id: bundle.as_str().to_string(),
1671                pack_id: "typo-pack".to_string(),
1672                flow_id: "main".to_string(),
1673                idempotency_key: Some("k3".to_string()),
1674                updated_by: "tester".to_string(),
1675            }),
1676        )
1677        .unwrap_err();
1678        assert!(
1679            matches!(err, OpError::InvalidArgument(_)),
1680            "typo'd pack_id must be rejected when bundle has current revisions to compare against, got: {err:?}"
1681        );
1682        // Real pack id (from make_revision's pack_list[0]) — must succeed.
1683        let ok = set_welcome_flow(
1684            &store,
1685            &OpFlags::default(),
1686            Some(EndpointSetWelcomeFlowPayload {
1687                environment_id: "local".to_string(),
1688                endpoint_id: id.to_string(),
1689                bundle_id: bundle.as_str().to_string(),
1690                pack_id: "greentic.test.pack".to_string(),
1691                flow_id: "main".to_string(),
1692                idempotency_key: Some("k4".to_string()),
1693                updated_by: "tester".to_string(),
1694            }),
1695        );
1696        assert!(
1697            ok.is_ok(),
1698            "pack_id matching the bundle's pack_list must be accepted"
1699        );
1700    }
1701
1702    #[test]
1703    fn set_welcome_flow_accepts_any_pack_id_when_bundle_has_no_revisions_yet() {
1704        // Companion to the above: bundles deployed but not yet staged carry
1705        // an empty `current_revisions`; the validator must skip the typo
1706        // guard rather than blocking the onboarding flow where set-welcome-
1707        // flow is wired before stage.
1708        let (_dir, store, bundle_ids) = seeded_store_with_bundles(&["legal-pack"]);
1709        let bundle = &bundle_ids[0];
1710        let added = add(
1711            &store,
1712            &OpFlags::default(),
1713            Some(add_payload("teams", "legal", "k1")),
1714        )
1715        .unwrap();
1716        let id = endpoint_id_from(&added);
1717        link_bundle(
1718            &store,
1719            &OpFlags::default(),
1720            Some(EndpointLinkBundlePayload {
1721                environment_id: "local".to_string(),
1722                endpoint_id: id.to_string(),
1723                bundle_id: bundle.as_str().to_string(),
1724                idempotency_key: Some("k2".to_string()),
1725                updated_by: "tester".to_string(),
1726            }),
1727        )
1728        .unwrap();
1729        let outcome = set_welcome_flow(
1730            &store,
1731            &OpFlags::default(),
1732            Some(EndpointSetWelcomeFlowPayload {
1733                environment_id: "local".to_string(),
1734                endpoint_id: id.to_string(),
1735                bundle_id: bundle.as_str().to_string(),
1736                pack_id: "future-pack".to_string(),
1737                flow_id: "main".to_string(),
1738                idempotency_key: Some("k3".to_string()),
1739                updated_by: "tester".to_string(),
1740            }),
1741        )
1742        .unwrap();
1743        assert_eq!(outcome.result["welcome_flow"]["pack_id"], "future-pack");
1744    }
1745
1746    #[test]
1747    fn add_with_missing_required_field_is_invalid_argument() {
1748        let (_dir, store, _) = seeded_store_with_bundles(&[]);
1749        let bad = EndpointAddPayload {
1750            environment_id: "local".to_string(),
1751            provider_id: "".to_string(),
1752            provider_type: "teams".to_string(),
1753            display_name: "x".to_string(),
1754            secret_refs: vec![],
1755            webhook_secret_ref: None,
1756            idempotency_key: Some("k1".to_string()),
1757            updated_by: "tester".to_string(),
1758        };
1759        let err = add(&store, &OpFlags::default(), Some(bad)).unwrap_err();
1760        assert!(matches!(err, OpError::InvalidArgument(_)));
1761    }
1762
1763    // --- webhook_secret_ref auto-gen + rotate tests -----------------------------
1764
1765    /// Load the raw `MessagingEndpoint` from the store for a given endpoint id.
1766    fn load_raw_endpoint(
1767        store: &LocalFsStore,
1768        endpoint_id: &MessagingEndpointId,
1769    ) -> MessagingEndpoint {
1770        let env_id = EnvId::try_from("local").unwrap();
1771        let env = store.load(&env_id).unwrap();
1772        env.messaging_endpoints
1773            .into_iter()
1774            .find(|e| e.endpoint_id == *endpoint_id)
1775            .expect("endpoint must exist")
1776    }
1777
1778    /// Read back a secret value from the env's dev-store. Mirrors
1779    /// `cli/secrets.rs::read_back` — sync tests build a fresh
1780    /// current-thread runtime, no `std::thread::scope` needed (there is no
1781    /// outer runtime to nest under).
1782    fn read_devstore_value(store: &LocalFsStore, secret_ref: &SecretRef) -> String {
1783        use greentic_secrets_lib::{DevStore, SecretsStore};
1784        let env_id = EnvId::try_from("local").unwrap();
1785        let env_dir = store.env_dir(&env_id).unwrap();
1786        let dev_path = crate::cli::secrets::resolve_dev_store_path(
1787            &env_dir,
1788            std::env::var_os(crate::cli::secrets::DEV_SECRETS_PATH_ENV).map(PathBuf::from),
1789        );
1790        let store_uri =
1791            crate::cli::secrets::secret_ref_to_store_uri(secret_ref).expect("store-aligned ref");
1792        let dev = DevStore::with_path(dev_path).expect("open dev store");
1793        let bytes = tokio::runtime::Builder::new_current_thread()
1794            .enable_all()
1795            .build()
1796            .unwrap()
1797            .block_on(async { dev.get(&store_uri).await.unwrap() });
1798        bytes.into_iter().map(|b| b as char).collect()
1799    }
1800
1801    #[test]
1802    fn add_telegram_generates_webhook_secret_ref() {
1803        let (_dir, store, _) = seeded_store_with_bundles(&[]);
1804        let outcome = add(
1805            &store,
1806            &OpFlags::default(),
1807            Some(add_payload("telegram", "legal-bot", "k1")),
1808        )
1809        .unwrap();
1810        let id = endpoint_id_from(&outcome);
1811        let ep = load_raw_endpoint(&store, &id);
1812        let secret_ref = ep
1813            .webhook_secret_ref
1814            .as_ref()
1815            .expect("telegram must have webhook_secret_ref");
1816        let eid_lower = id.to_string().to_lowercase();
1817        let expected_uri = format!("secret://local/default/_/messaging-{eid_lower}/webhook_secret");
1818        assert_eq!(secret_ref.as_str(), expected_uri);
1819        // Verify the actual value was written to the dev-store.
1820        let value = read_devstore_value(&store, secret_ref);
1821        assert!(
1822            value.len() >= 32,
1823            "dev-store secret must be ≥32 chars, got {}",
1824            value.len()
1825        );
1826    }
1827
1828    #[test]
1829    fn add_telegram_with_supplied_ref_stamps_it_and_skips_auto_mint() {
1830        let (_dir, store, _) = seeded_store_with_bundles(&[]);
1831        let supplied = "secret://local/default/_/messaging-byo/webhook_secret";
1832        let outcome = add(
1833            &store,
1834            &OpFlags::default(),
1835            Some(EndpointAddPayload {
1836                webhook_secret_ref: Some(supplied.to_string()),
1837                ..add_payload("telegram", "legal-bot", "k1")
1838            }),
1839        )
1840        .unwrap();
1841        let id = endpoint_id_from(&outcome);
1842        let ep = load_raw_endpoint(&store, &id);
1843        // The supplied ref is stamped verbatim — NOT the eid-derived URI the
1844        // auto-mint path would build.
1845        assert_eq!(
1846            ep.webhook_secret_ref.as_ref().map(|r| r.as_str()),
1847            Some(supplied)
1848        );
1849    }
1850
1851    #[test]
1852    fn add_telegram_dotted_generates_webhook_secret_ref() {
1853        let (_dir, store, _) = seeded_store_with_bundles(&[]);
1854        let outcome = add(
1855            &store,
1856            &OpFlags::default(),
1857            Some(add_payload("messaging.telegram.bot", "tg-bot", "k1")),
1858        )
1859        .unwrap();
1860        let id = endpoint_id_from(&outcome);
1861        let ep = load_raw_endpoint(&store, &id);
1862        assert!(
1863            ep.webhook_secret_ref.is_some(),
1864            "messaging.telegram.bot must auto-gen webhook_secret_ref"
1865        );
1866    }
1867
1868    #[test]
1869    fn add_non_telegram_has_no_webhook_secret_ref() {
1870        let (_dir, store, _) = seeded_store_with_bundles(&[]);
1871        let outcome = add(
1872            &store,
1873            &OpFlags::default(),
1874            Some(add_payload("slack", "ops", "k1")),
1875        )
1876        .unwrap();
1877        let id = endpoint_id_from(&outcome);
1878        let ep = load_raw_endpoint(&store, &id);
1879        assert!(
1880            ep.webhook_secret_ref.is_none(),
1881            "non-telegram provider must not auto-gen webhook_secret_ref"
1882        );
1883    }
1884
1885    #[test]
1886    fn idempotent_add_preserves_original_webhook_secret_ref() {
1887        let (_dir, store, _) = seeded_store_with_bundles(&[]);
1888        let first = add(
1889            &store,
1890            &OpFlags::default(),
1891            Some(add_payload("telegram", "legal-bot", "k-replay")),
1892        )
1893        .unwrap();
1894        let id = endpoint_id_from(&first);
1895        let ref_1 = load_raw_endpoint(&store, &id)
1896            .webhook_secret_ref
1897            .expect("first call must generate a ref");
1898        let value_1 = read_devstore_value(&store, &ref_1);
1899        // Replay with same idem key — the idempotent path returns BEFORE
1900        // any DevStore write, so the URI and the stored value are unchanged.
1901        add(
1902            &store,
1903            &OpFlags::default(),
1904            Some(add_payload("telegram", "legal-bot", "k-replay")),
1905        )
1906        .unwrap();
1907        let ref_2 = load_raw_endpoint(&store, &id)
1908            .webhook_secret_ref
1909            .expect("replay must preserve the ref");
1910        let value_2 = read_devstore_value(&store, &ref_2);
1911        assert_eq!(
1912            ref_1, ref_2,
1913            "idempotent replay must return the SAME SecretRef"
1914        );
1915        assert_eq!(
1916            value_1, value_2,
1917            "idempotent replay must not overwrite the dev-store value"
1918        );
1919    }
1920
1921    #[test]
1922    fn webhook_secret_ref_passes_deploy_spec_validate() {
1923        let (_dir, store, _) = seeded_store_with_bundles(&[]);
1924        let outcome = add(
1925            &store,
1926            &OpFlags::default(),
1927            Some(add_payload("telegram", "legal-bot", "k1")),
1928        )
1929        .unwrap();
1930        let id = endpoint_id_from(&outcome);
1931        let ep = load_raw_endpoint(&store, &id);
1932        ep.validate()
1933            .expect("endpoint with webhook_secret_ref must pass deploy-spec validate");
1934    }
1935
1936    /// Like [`read_devstore_value`] but does not panic when the key is absent —
1937    /// used to assert that non-custodial provisioning writes NOTHING.
1938    fn devstore_has_value(store: &LocalFsStore, secret_ref: &SecretRef) -> bool {
1939        use greentic_secrets_lib::{DevStore, SecretsStore};
1940        let env_id = EnvId::try_from("local").unwrap();
1941        let env_dir = store.env_dir(&env_id).unwrap();
1942        let dev_path = crate::cli::secrets::resolve_dev_store_path(
1943            &env_dir,
1944            std::env::var_os(crate::cli::secrets::DEV_SECRETS_PATH_ENV).map(PathBuf::from),
1945        );
1946        let store_uri =
1947            crate::cli::secrets::secret_ref_to_store_uri(secret_ref).expect("store-aligned ref");
1948        // The dev-store file only exists once something has written to it; a
1949        // non-custodial provision writes nothing, so an absent store == no value.
1950        let Ok(dev) = DevStore::with_path(dev_path) else {
1951            return false;
1952        };
1953        tokio::runtime::Builder::new_current_thread()
1954            .enable_all()
1955            .build()
1956            .unwrap()
1957            .block_on(async { dev.get(&store_uri).await.is_ok() })
1958    }
1959
1960    #[test]
1961    fn build_webhook_secret_ref_uses_provided_tenant() {
1962        // The tenant segment is the env owner, not a hardcoded `default`, so the
1963        // ref resolves under a Vault-backed worker's tenant-scoped SecretsCore.
1964        let env_id = EnvId::try_from("vault-demo").unwrap();
1965        let eid = MessagingEndpointId::new();
1966        let eid_lower = eid.to_string().to_lowercase();
1967        let r = build_webhook_secret_ref(&env_id, &eid, "tenant-default").unwrap();
1968        assert_eq!(
1969            r.as_str(),
1970            format!("secret://vault-demo/tenant-default/_/messaging-{eid_lower}/webhook_secret")
1971        );
1972    }
1973
1974    #[test]
1975    fn provision_webhook_secret_honors_tenant_and_custodial_gate() {
1976        let (_dir, store, _) = seeded_store_with_bundles(&[]);
1977        let env_id = EnvId::try_from("local").unwrap();
1978        let eid = MessagingEndpointId::new();
1979        let eid_lower = eid.to_string().to_lowercase();
1980
1981        // Non-custodial (e.g. Vault): builds the tenant-scoped ref but writes
1982        // NOTHING to the dev-store — the operator seeds the value out-of-band.
1983        let ref_vault =
1984            provision_webhook_secret(&store, &env_id, &eid, Some("tenant-default"), false, None)
1985                .unwrap();
1986        assert_eq!(
1987            ref_vault.as_str(),
1988            format!("secret://local/tenant-default/_/messaging-{eid_lower}/webhook_secret")
1989        );
1990        assert!(
1991            !devstore_has_value(&store, &ref_vault),
1992            "non-custodial provisioning must not write a dev-store value"
1993        );
1994
1995        // Custodial (dev-store): the value is minted and written at the ref.
1996        // No owner → the conventional `default` tenant segment.
1997        let ref_dev = provision_webhook_secret(&store, &env_id, &eid, None, true, None).unwrap();
1998        assert_eq!(
1999            ref_dev.as_str(),
2000            format!("secret://local/default/_/messaging-{eid_lower}/webhook_secret")
2001        );
2002        assert!(
2003            devstore_has_value(&store, &ref_dev),
2004            "custodial provisioning must write the dev-store value"
2005        );
2006        assert!(
2007            read_devstore_value(&store, &ref_dev).len() >= 32,
2008            "custodial webhook secret must be ≥32 chars"
2009        );
2010    }
2011
2012    #[test]
2013    fn provision_webhook_secret_fails_closed_on_non_custodial_without_owner() {
2014        let (_dir, store, _) = seeded_store_with_bundles(&[]);
2015        let env_id = EnvId::try_from("local").unwrap();
2016        let eid = MessagingEndpointId::new();
2017        // A non-custodial backend (e.g. Vault) scopes reads to the env owner, so a
2018        // missing owner would mint an unresolvable `default` ref — fail closed.
2019        let err = provision_webhook_secret(&store, &env_id, &eid, None, false, None).unwrap_err();
2020        let OpError::Conflict(msg) = err else {
2021            panic!("expected OpError::Conflict");
2022        };
2023        assert!(msg.contains("owning tenant"), "got: {msg}");
2024    }
2025
2026    fn rotate_payload(endpoint_id: &str, key: &str) -> EndpointRotateWebhookSecretPayload {
2027        EndpointRotateWebhookSecretPayload {
2028            environment_id: "local".to_string(),
2029            endpoint_id: endpoint_id.to_string(),
2030            idempotency_key: Some(key.to_string()),
2031            updated_by: "tester".to_string(),
2032            webhook_secret_ref: None,
2033        }
2034    }
2035
2036    #[test]
2037    fn rotate_webhook_secret_changes_devstore_value_and_bumps_generation() {
2038        let (_dir, store, _) = seeded_store_with_bundles(&[]);
2039        let added = add(
2040            &store,
2041            &OpFlags::default(),
2042            Some(add_payload("telegram", "legal-bot", "k1")),
2043        )
2044        .unwrap();
2045        let id = endpoint_id_from(&added);
2046        let before = load_raw_endpoint(&store, &id);
2047        let before_ref = before.webhook_secret_ref.clone().expect("has ref");
2048        let before_value = read_devstore_value(&store, &before_ref);
2049        let before_gen = before.generation;
2050        rotate_webhook_secret(
2051            &store,
2052            &OpFlags::default(),
2053            Some(rotate_payload(&id.to_string(), "k-rotate")),
2054        )
2055        .unwrap();
2056        let after = load_raw_endpoint(&store, &id);
2057        let after_ref = after
2058            .webhook_secret_ref
2059            .as_ref()
2060            .expect("rotated endpoint must have ref");
2061        let after_value = read_devstore_value(&store, after_ref);
2062        assert_eq!(
2063            before_ref, *after_ref,
2064            "rotate must preserve the same URI ref"
2065        );
2066        assert_ne!(
2067            before_value, after_value,
2068            "rotate must produce a different dev-store value"
2069        );
2070        assert!(after_value.len() >= 32, "rotated secret must be ≥32 chars");
2071        assert_eq!(
2072            after.generation,
2073            before_gen + 1,
2074            "rotate must bump generation"
2075        );
2076    }
2077
2078    #[test]
2079    fn local_rotate_rejects_supplied_webhook_ref() {
2080        let (_dir, store, _) = seeded_store_with_bundles(&[]);
2081        let added = add(
2082            &store,
2083            &OpFlags::default(),
2084            Some(add_payload("telegram", "legal-bot", "k1")),
2085        )
2086        .unwrap();
2087        let id = endpoint_id_from(&added);
2088        // The new-ref variant is remote-store-only: the local store mints its
2089        // own value+ref, so a caller-supplied ref fails closed rather than
2090        // being silently ignored.
2091        let mut payload = rotate_payload(&id.to_string(), "k-rotate");
2092        payload.webhook_secret_ref =
2093            Some("secret://local/default/_/messaging-x/webhook_secret".to_string());
2094        let err = rotate_webhook_secret(&store, &OpFlags::default(), Some(payload)).unwrap_err();
2095        assert!(
2096            matches!(err, OpError::InvalidArgument(_)),
2097            "expected InvalidArgument, got {err:?}"
2098        );
2099    }
2100
2101    #[test]
2102    fn idempotent_rotate_preserves_devstore_value() {
2103        let (_dir, store, _) = seeded_store_with_bundles(&[]);
2104        let added = add(
2105            &store,
2106            &OpFlags::default(),
2107            Some(add_payload("telegram", "legal-bot", "k1")),
2108        )
2109        .unwrap();
2110        let id = endpoint_id_from(&added);
2111        rotate_webhook_secret(
2112            &store,
2113            &OpFlags::default(),
2114            Some(rotate_payload(&id.to_string(), "k-rotate")),
2115        )
2116        .unwrap();
2117        // One load per snapshot — `ref_N` and `gen_N` come from the SAME
2118        // snapshot so they can't theoretically diverge under concurrent writers.
2119        let snapshot_1 = load_raw_endpoint(&store, &id);
2120        let ref_1 = snapshot_1.webhook_secret_ref.clone().expect("has ref");
2121        let gen_1 = snapshot_1.generation;
2122        let value_1 = read_devstore_value(&store, &ref_1);
2123        // Replay with same idem key.
2124        rotate_webhook_secret(
2125            &store,
2126            &OpFlags::default(),
2127            Some(rotate_payload(&id.to_string(), "k-rotate")),
2128        )
2129        .unwrap();
2130        let snapshot_2 = load_raw_endpoint(&store, &id);
2131        let ref_2 = snapshot_2.webhook_secret_ref.clone().expect("has ref");
2132        let gen_2 = snapshot_2.generation;
2133        let value_2 = read_devstore_value(&store, &ref_2);
2134        assert_eq!(ref_1, ref_2, "idempotent replay must preserve the URI ref");
2135        assert_eq!(
2136            value_1, value_2,
2137            "idempotent rotate replay must preserve the dev-store value"
2138        );
2139        assert_eq!(gen_1, gen_2, "idempotent replay must not bump generation");
2140    }
2141
2142    // ---- inline-flag → payload conversion ---------------------------------
2143
2144    fn add_args_full() -> MessagingEndpointAddArgs {
2145        MessagingEndpointAddArgs {
2146            env: Some("local".into()),
2147            provider_type: Some("telegram".into()),
2148            provider_id: Some("legal-bot".into()),
2149            display_name: Some("Legal Bot".into()),
2150            secret_ref: vec![],
2151            webhook_secret_ref: None,
2152            idempotency_key: Some("k1".into()),
2153            updated_by: Some("alice".into()),
2154        }
2155    }
2156
2157    #[test]
2158    fn add_args_with_all_flags_yields_payload() {
2159        let payload = add_args_full()
2160            .into_payload("add", &OpFlags::default())
2161            .expect("ok")
2162            .expect("some");
2163        assert_eq!(payload.environment_id, "local");
2164        assert_eq!(payload.provider_type, "telegram");
2165        assert_eq!(payload.provider_id, "legal-bot");
2166        assert_eq!(payload.display_name, "Legal Bot");
2167        assert_eq!(payload.idempotency_key, Some("k1".to_string()));
2168        assert_eq!(payload.updated_by, "alice");
2169        assert!(payload.secret_refs.is_empty());
2170    }
2171
2172    #[test]
2173    fn add_args_propagates_secret_refs() {
2174        let mut args = add_args_full();
2175        args.secret_ref = vec![
2176            "secret://local/global/telegram/bot_token".into(),
2177            "secret://local/global/telegram/api_key".into(),
2178        ];
2179        let payload = args
2180            .into_payload("add", &OpFlags::default())
2181            .expect("ok")
2182            .expect("some");
2183        assert_eq!(payload.secret_refs.len(), 2);
2184        assert_eq!(
2185            payload.secret_refs[0],
2186            "secret://local/global/telegram/bot_token"
2187        );
2188    }
2189
2190    #[test]
2191    fn add_args_missing_required_field_is_rejected() {
2192        let mut args = add_args_full();
2193        args.display_name = None;
2194        let err = args.into_payload("add", &OpFlags::default()).unwrap_err();
2195        assert!(matches!(err, OpError::InvalidArgument(_)));
2196        let msg = format!("{err:?}");
2197        assert!(
2198            msg.contains("--display-name"),
2199            "error must name the missing flag: {msg}"
2200        );
2201    }
2202
2203    #[test]
2204    fn add_args_missing_idem_key_is_rejected() {
2205        let mut args = add_args_full();
2206        args.idempotency_key = None;
2207        let err = args.into_payload("add", &OpFlags::default()).unwrap_err();
2208        assert!(matches!(err, OpError::InvalidArgument(_)));
2209        let msg = format!("{err:?}");
2210        assert!(
2211            msg.contains("--idempotency-key"),
2212            "error must name the missing flag: {msg}"
2213        );
2214    }
2215
2216    #[test]
2217    fn add_args_no_flags_returns_none() {
2218        let args = MessagingEndpointAddArgs {
2219            env: None,
2220            provider_type: None,
2221            provider_id: None,
2222            display_name: None,
2223            secret_ref: vec![],
2224            webhook_secret_ref: None,
2225            idempotency_key: None,
2226            updated_by: None,
2227        };
2228        assert!(
2229            args.into_payload("add", &OpFlags::default())
2230                .unwrap()
2231                .is_none()
2232        );
2233    }
2234
2235    #[test]
2236    fn link_bundle_args_with_all_flags_yields_payload() {
2237        let args = MessagingEndpointLinkBundleArgs {
2238            env: Some("local".into()),
2239            endpoint_id: Some("01HXYZ...".into()),
2240            bundle_id: Some("legal".into()),
2241            idempotency_key: Some("k-link".into()),
2242            updated_by: Some("alice".into()),
2243        };
2244        let payload = args
2245            .into_payload("link-bundle", &OpFlags::default())
2246            .expect("ok")
2247            .expect("some");
2248        assert_eq!(payload.environment_id, "local");
2249        assert_eq!(payload.endpoint_id, "01HXYZ...");
2250        assert_eq!(payload.bundle_id, "legal");
2251    }
2252
2253    #[test]
2254    fn link_bundle_args_missing_bundle_is_rejected() {
2255        let args = MessagingEndpointLinkBundleArgs {
2256            env: Some("local".into()),
2257            endpoint_id: Some("01HXYZ".into()),
2258            bundle_id: None,
2259            idempotency_key: Some("k".into()),
2260            updated_by: Some("alice".into()),
2261        };
2262        let err = args
2263            .into_payload("link-bundle", &OpFlags::default())
2264            .unwrap_err();
2265        assert!(matches!(err, OpError::InvalidArgument(_)));
2266        let msg = format!("{err:?}");
2267        assert!(
2268            msg.contains("--bundle-id"),
2269            "error must name the missing flag: {msg}"
2270        );
2271    }
2272
2273    #[test]
2274    fn link_bundle_args_no_flags_returns_none() {
2275        let args = MessagingEndpointLinkBundleArgs {
2276            env: None,
2277            endpoint_id: None,
2278            bundle_id: None,
2279            idempotency_key: None,
2280            updated_by: None,
2281        };
2282        assert!(
2283            args.into_payload("link-bundle", &OpFlags::default())
2284                .unwrap()
2285                .is_none()
2286        );
2287    }
2288
2289    #[test]
2290    fn set_welcome_flow_args_with_all_flags_yields_payload() {
2291        let args = MessagingEndpointSetWelcomeFlowArgs {
2292            env: Some("local".into()),
2293            endpoint_id: Some("01HXYZ".into()),
2294            bundle_id: Some("legal".into()),
2295            pack_id: Some("welcome".into()),
2296            flow_id: Some("hello".into()),
2297            idempotency_key: Some("k-set".into()),
2298            updated_by: Some("alice".into()),
2299        };
2300        let payload = args
2301            .into_payload("set-welcome-flow", &OpFlags::default())
2302            .expect("ok")
2303            .expect("some");
2304        assert_eq!(payload.pack_id, "welcome");
2305        assert_eq!(payload.flow_id, "hello");
2306    }
2307
2308    #[test]
2309    fn set_welcome_flow_args_missing_flow_id_is_rejected() {
2310        let args = MessagingEndpointSetWelcomeFlowArgs {
2311            env: Some("local".into()),
2312            endpoint_id: Some("01HXYZ".into()),
2313            bundle_id: Some("legal".into()),
2314            pack_id: Some("welcome".into()),
2315            flow_id: None,
2316            idempotency_key: Some("k".into()),
2317            updated_by: Some("alice".into()),
2318        };
2319        let err = args
2320            .into_payload("set-welcome-flow", &OpFlags::default())
2321            .unwrap_err();
2322        assert!(matches!(err, OpError::InvalidArgument(_)));
2323        let msg = format!("{err:?}");
2324        assert!(
2325            msg.contains("--flow-id"),
2326            "error must name the missing flag: {msg}"
2327        );
2328    }
2329
2330    #[test]
2331    fn set_welcome_flow_args_no_flags_returns_none() {
2332        let args = MessagingEndpointSetWelcomeFlowArgs {
2333            env: None,
2334            endpoint_id: None,
2335            bundle_id: None,
2336            pack_id: None,
2337            flow_id: None,
2338            idempotency_key: None,
2339            updated_by: None,
2340        };
2341        assert!(
2342            args.into_payload("set-welcome-flow", &OpFlags::default())
2343                .unwrap()
2344                .is_none()
2345        );
2346    }
2347
2348    fn remove_args_full() -> MessagingEndpointRemoveArgs {
2349        MessagingEndpointRemoveArgs {
2350            env: Some("local".into()),
2351            endpoint_id: Some("01HXYZ".into()),
2352            idempotency_key: Some("k-rm".into()),
2353            updated_by: Some("alice".into()),
2354        }
2355    }
2356
2357    #[test]
2358    fn remove_args_with_all_flags_yields_remove_payload() {
2359        let payload = remove_args_full()
2360            .into_remove_payload("remove", &OpFlags::default())
2361            .expect("ok")
2362            .expect("some");
2363        assert_eq!(payload.environment_id, "local");
2364        assert_eq!(payload.endpoint_id, "01HXYZ");
2365        assert_eq!(payload.idempotency_key, Some("k-rm".to_string()));
2366    }
2367
2368    #[test]
2369    fn remove_args_with_all_flags_yields_rotate_payload() {
2370        let payload = remove_args_full()
2371            .into_rotate_payload("rotate-webhook-secret", &OpFlags::default())
2372            .expect("ok")
2373            .expect("some");
2374        assert_eq!(payload.environment_id, "local");
2375        assert_eq!(payload.endpoint_id, "01HXYZ");
2376    }
2377
2378    #[test]
2379    fn remove_args_missing_endpoint_id_is_rejected() {
2380        let mut args = remove_args_full();
2381        args.endpoint_id = None;
2382        let err = args
2383            .into_remove_payload("remove", &OpFlags::default())
2384            .unwrap_err();
2385        assert!(matches!(err, OpError::InvalidArgument(_)));
2386        let msg = format!("{err:?}");
2387        assert!(
2388            msg.contains("--endpoint-id"),
2389            "error must name the missing flag: {msg}"
2390        );
2391        // The rotate path takes the same args struct — same rejection
2392        // semantics on the same field set.
2393        let mut args = remove_args_full();
2394        args.endpoint_id = None;
2395        let err = args
2396            .into_rotate_payload("rotate-webhook-secret", &OpFlags::default())
2397            .unwrap_err();
2398        assert!(matches!(err, OpError::InvalidArgument(_)));
2399    }
2400
2401    #[test]
2402    fn remove_args_no_flags_returns_none() {
2403        let args = MessagingEndpointRemoveArgs {
2404            env: None,
2405            endpoint_id: None,
2406            idempotency_key: None,
2407            updated_by: None,
2408        };
2409        assert!(
2410            args.into_remove_payload("remove", &OpFlags::default())
2411                .unwrap()
2412                .is_none()
2413        );
2414        let args = MessagingEndpointRemoveArgs {
2415            env: None,
2416            endpoint_id: None,
2417            idempotency_key: None,
2418            updated_by: None,
2419        };
2420        assert!(
2421            args.into_rotate_payload("rotate-webhook-secret", &OpFlags::default())
2422                .unwrap()
2423                .is_none()
2424        );
2425    }
2426
2427    // --- partial inline flags: the destructive-misdirection footgun -----------
2428    //
2429    // Each verb must reject partial inline flags with a precise error listing
2430    // what's missing, rather than silently falling through to --answers.
2431
2432    #[test]
2433    fn add_partial_flags_rejected_with_missing_list() {
2434        // Supply env + provider-type, omit the rest.
2435        let args = MessagingEndpointAddArgs {
2436            env: Some("prod".into()),
2437            provider_type: Some("telegram".into()),
2438            provider_id: None,
2439            display_name: None,
2440            secret_ref: vec![],
2441            webhook_secret_ref: None,
2442            idempotency_key: None,
2443            updated_by: None,
2444        };
2445        let err = args.into_payload("add", &OpFlags::default()).unwrap_err();
2446        let msg = format!("{err:?}");
2447        assert!(
2448            msg.contains("--provider-id"),
2449            "must list --provider-id: {msg}"
2450        );
2451        assert!(
2452            msg.contains("--display-name"),
2453            "must list --display-name: {msg}"
2454        );
2455        assert!(
2456            msg.contains("--idempotency-key"),
2457            "must list --idempotency-key: {msg}"
2458        );
2459        assert!(
2460            msg.contains("--updated-by"),
2461            "must list --updated-by: {msg}"
2462        );
2463    }
2464
2465    #[test]
2466    fn link_bundle_partial_flags_rejected() {
2467        let args = MessagingEndpointLinkBundleArgs {
2468            env: Some("prod".into()),
2469            endpoint_id: Some("01HXYZ".into()),
2470            bundle_id: None,
2471            idempotency_key: None,
2472            updated_by: Some("alice".into()),
2473        };
2474        let err = args
2475            .into_payload("link-bundle", &OpFlags::default())
2476            .unwrap_err();
2477        let msg = format!("{err:?}");
2478        assert!(msg.contains("--bundle-id"), "must list --bundle-id: {msg}");
2479        assert!(
2480            msg.contains("--idempotency-key"),
2481            "must list --idempotency-key: {msg}"
2482        );
2483    }
2484
2485    #[test]
2486    fn unlink_bundle_partial_flags_rejected() {
2487        let args = MessagingEndpointLinkBundleArgs {
2488            env: Some("prod".into()),
2489            endpoint_id: None,
2490            bundle_id: Some("legal".into()),
2491            idempotency_key: Some("k".into()),
2492            updated_by: None,
2493        };
2494        let err = args
2495            .into_payload("unlink-bundle", &OpFlags::default())
2496            .unwrap_err();
2497        let msg = format!("{err:?}");
2498        assert!(
2499            msg.contains("--endpoint-id"),
2500            "must list --endpoint-id: {msg}"
2501        );
2502        assert!(
2503            msg.contains("--updated-by"),
2504            "must list --updated-by: {msg}"
2505        );
2506    }
2507
2508    #[test]
2509    fn set_welcome_flow_partial_flags_rejected() {
2510        let args = MessagingEndpointSetWelcomeFlowArgs {
2511            env: Some("prod".into()),
2512            endpoint_id: Some("01HXYZ".into()),
2513            bundle_id: Some("legal".into()),
2514            pack_id: None,
2515            flow_id: None,
2516            idempotency_key: Some("k".into()),
2517            updated_by: Some("alice".into()),
2518        };
2519        let err = args
2520            .into_payload("set-welcome-flow", &OpFlags::default())
2521            .unwrap_err();
2522        let msg = format!("{err:?}");
2523        assert!(msg.contains("--pack-id"), "must list --pack-id: {msg}");
2524        assert!(msg.contains("--flow-id"), "must list --flow-id: {msg}");
2525    }
2526
2527    #[test]
2528    fn remove_partial_flags_rejected() {
2529        let args = MessagingEndpointRemoveArgs {
2530            env: Some("prod".into()),
2531            endpoint_id: Some("01HXYZ".into()),
2532            idempotency_key: None,
2533            updated_by: None,
2534        };
2535        let err = args
2536            .into_remove_payload("remove", &OpFlags::default())
2537            .unwrap_err();
2538        let msg = format!("{err:?}");
2539        assert!(
2540            msg.contains("--idempotency-key"),
2541            "must list --idempotency-key: {msg}"
2542        );
2543        assert!(
2544            msg.contains("--updated-by"),
2545            "must list --updated-by: {msg}"
2546        );
2547    }
2548
2549    #[test]
2550    fn rotate_webhook_secret_partial_flags_rejected() {
2551        let args = MessagingEndpointRemoveArgs {
2552            env: Some("prod".into()),
2553            endpoint_id: None,
2554            idempotency_key: Some("k".into()),
2555            updated_by: Some("alice".into()),
2556        };
2557        let err = args
2558            .into_rotate_payload("rotate-webhook-secret", &OpFlags::default())
2559            .unwrap_err();
2560        let msg = format!("{err:?}");
2561        assert!(
2562            msg.contains("--endpoint-id"),
2563            "must list --endpoint-id: {msg}"
2564        );
2565    }
2566
2567    // --- inline flags + --answers mutual exclusion ----------------------------
2568
2569    fn flags_with_answers() -> OpFlags {
2570        OpFlags {
2571            schema_only: false,
2572            answers: Some(std::path::PathBuf::from("stale.json")),
2573        }
2574    }
2575
2576    #[test]
2577    fn add_inline_plus_answers_is_rejected() {
2578        // Inline flags + --answers is always misuse: the converter rejects
2579        // before any payload work, so a destructive `remove` with both forms
2580        // can never proceed against a stale answers file.
2581        let err = remove_args_full()
2582            .into_remove_payload("remove", &flags_with_answers())
2583            .unwrap_err();
2584        let msg = format!("{err:?}");
2585        assert!(
2586            msg.contains("mutually exclusive"),
2587            "must mention mutual exclusion: {msg}"
2588        );
2589    }
2590
2591    #[test]
2592    fn rotate_inline_plus_answers_is_rejected() {
2593        let err = remove_args_full()
2594            .into_rotate_payload("rotate-webhook-secret", &flags_with_answers())
2595            .unwrap_err();
2596        assert!(matches!(err, OpError::InvalidArgument(_)));
2597    }
2598
2599    #[test]
2600    fn add_args_inline_plus_answers_is_rejected() {
2601        let err = add_args_full()
2602            .into_payload("add", &flags_with_answers())
2603            .unwrap_err();
2604        assert!(matches!(err, OpError::InvalidArgument(_)));
2605    }
2606
2607    #[test]
2608    fn no_inline_flags_plus_answers_is_ok() {
2609        // Pure --answers path (no inline flags) must NOT be rejected.
2610        let args = MessagingEndpointRemoveArgs {
2611            env: None,
2612            endpoint_id: None,
2613            idempotency_key: None,
2614            updated_by: None,
2615        };
2616        assert!(
2617            args.into_remove_payload("remove", &flags_with_answers())
2618                .unwrap()
2619                .is_none()
2620        );
2621    }
2622
2623    // End-to-end: CLI-derived payload feeds the verb the same way `--answers`
2624    // does, and the resulting endpoint is materialized correctly.
2625    #[test]
2626    fn cli_derived_add_payload_drives_verb_e2e() {
2627        let (_dir, store, _) = seeded_store_with_bundles(&[]);
2628        let payload = MessagingEndpointAddArgs {
2629            env: Some("local".into()),
2630            provider_type: Some("teams".into()),
2631            provider_id: Some("cli-bot".into()),
2632            display_name: Some("CLI Bot".into()),
2633            secret_ref: vec![],
2634            webhook_secret_ref: None,
2635            idempotency_key: Some("cli-add-1".into()),
2636            updated_by: Some("cli".into()),
2637        }
2638        .into_payload("add", &OpFlags::default())
2639        .expect("ok")
2640        .expect("some");
2641        let outcome = add(&store, &OpFlags::default(), Some(payload)).unwrap();
2642        assert_eq!(outcome.result["provider_type"], "teams");
2643        assert_eq!(outcome.result["provider_id"], "cli-bot");
2644        assert_eq!(outcome.result["display_name"], "CLI Bot");
2645    }
2646}