Skip to main content

greentic_deploy_spec/engine/
messaging.rs

1//! Pure messaging-endpoint verb semantics (Phase D PR-4.2h).
2//!
3//! The messaging verb group (`op messaging endpoint add | link-bundle |
4//! unlink-bundle | set-welcome-flow | remove | rotate-webhook-secret`)
5//! follows the PR-4.2a engine contract: pure `&mut Environment` transforms
6//! with no I/O and no clock. Both `LocalFsStore` (greentic-deployer, behind
7//! a flock) and the operator-store-server (behind SQLite CAS) drive the
8//! SAME functions, so replay detection, duplicate rules, and welcome-flow
9//! validation cannot drift between local and remote.
10//!
11//! # The webhook-secret seam
12//!
13//! Telegram-class endpoints need a per-endpoint webhook secret. Generating
14//! the VALUE and deciding where it lives are backend concerns, so `add` and
15//! `rotate-webhook-secret` take a `provision` closure: it receives the
16//! endpoint's existing ref (`Some` when rotating an endpoint that already
17//! carries one, `None` otherwise) and returns the [`SecretRef`] to stamp on
18//! `MessagingEndpoint.webhook_secret_ref`. `LocalFsStore` mints the value
19//! and persists it into the env-pack dev-store
20//! (`cli::messaging::provision_webhook_secret`).
21//!
22//! Callers may instead SUPPLY the ref directly on `add` via
23//! [`AddMessagingEndpointPayload::webhook_secret_ref`]: when present for a
24//! telegram-class endpoint the transform validates and stamps it WITHOUT
25//! calling `provision`. Remote operator stores use this — the operator owns
26//! value provisioning in its own secrets plane and ships only the ref, so
27//! the control-plane store never custodies secret material. Its `provision`
28//! closure always refuses ([`MessagingError::SecretProvision`], mapped to
29//! 501): the server neither mints nor rotates secrets, so a telegram-class
30//! `add` over a remote store must supply the ref and `rotate-webhook-secret`
31//! is unsupported there (the server cannot prove a value rotated). The
32//! closure / supplied-ref step runs AFTER replay/duplicate/ref validation,
33//! so it never fires on a replay and never leaves a half-validated
34//! mutation.
35//!
36//! # Idempotency key is domain state here
37//!
38//! Unlike the bindings/bundles groups (key = transport metadata), this
39//! group embeds the key in `MessagingEndpoint.updated_by`
40//! (`"<by>#idem=<op>:<key>"`) and uses it for **operation-scoped**
41//! same-key replay detection on `add` and `rotate-webhook-secret` — so
42//! the transforms take the
43//! [`IdempotencyKey`](crate::IdempotencyKey) (the traffic-group
44//! precedent). The server hands the A8 `Idempotency-Key` header to the
45//! engine; the durable replay ledger remains PR-4.3.
46//!
47//! The operation name (`add`, `link-bundle`, `unlink-bundle`,
48//! `set-welcome-flow`, `rotate-webhook-secret`) is part of the stamp so
49//! a key used by one verb cannot satisfy a replay check for a different
50//! verb. Without the op in the stamp a `rotate-webhook-secret` replaying
51//! a key that was originally stamped by `add` would report success
52//! without actually rotating — a false SUCCESS that never provisions
53//! anything. Endpoints stamped under the old format (`"<by>#idem=<key>"`,
54//! without `<op>:`) simply never match the new replay checks; a
55//! cross-version retry re-executes instead of replaying, which is
56//! acceptable — crash-retry replay is best-effort until the PR-4.3
57//! request-fingerprint replay ledger.
58//!
59//! # Persist rule (read before calling)
60//!
61//! - `Ok(applied)` with `applied.mutated == true` — the env was mutated;
62//!   the backend persists.
63//! - `Ok(applied)` with `applied.mutated == false` — idempotent replay or
64//!   no-op; the env is UNTOUCHED, do not persist (the server echoes the
65//!   loaded CAS coordinates). `LocalFsStore` still refreshes its derived
66//!   `<env_dir>/messaging/` projection on this path — that repair step is
67//!   LocalFS-only, like the runtime-config projection.
68//! - `Err(_)` — the env was not mutated; nothing to persist.
69//!
70//! # Wire shapes
71//!
72//! [`AddMessagingEndpointPayload`] / [`MessagingBundleLinkPayload`] /
73//! [`SetMessagingWelcomeFlowPayload`] / [`RotateWebhookSecretPayload`]
74//! double as the A8 request bodies on the `/environments/{env}/messaging`
75//! routes the PR-3b client pinned. The wire-format tests at the bottom pin
76//! the encoding.
77
78use chrono::{DateTime, Utc};
79use serde::{Deserialize, Serialize};
80use thiserror::Error;
81
82use crate::environment::Environment;
83use crate::ids::{BundleId, MessagingEndpointId, PackId};
84use crate::messaging_endpoint::{MessagingEndpoint, WelcomeFlowRef};
85use crate::refs::SecretRef;
86use crate::remote::IdempotencyKey;
87use crate::version::SchemaVersion;
88use greentic_types::EnvId;
89
90// ---------------------------------------------------------------------------
91// Errors
92// ---------------------------------------------------------------------------
93
94/// Why a messaging verb refused to mutate the environment. Display strings
95/// are verbatim what `LocalFsStore` raised before the move (PR-4.2h), so
96/// operator-facing CLI errors are unchanged.
97#[derive(Debug, Clone, PartialEq, Eq, Error)]
98pub enum MessagingError {
99    /// Same-key replay whose payload identity does NOT match the endpoint
100    /// the key originally stamped — a key-reuse protocol violation.
101    #[error(
102        "idempotency key `{key}` already used to add `{provider_type}`/`{provider_id}` \
103         in env `{env_id}`; pass a fresh key"
104    )]
105    IdempotencyKeyReuse {
106        key: String,
107        provider_type: String,
108        provider_id: String,
109        env_id: EnvId,
110    },
111    /// `(provider_type, provider_id)` is unique per environment.
112    #[error(
113        "messaging endpoint with provider_type=`{provider_type}` provider_id=`{provider_id}` \
114         already exists in env `{env_id}`"
115    )]
116    EndpointAlreadyExists {
117        provider_type: String,
118        provider_id: String,
119        env_id: EnvId,
120    },
121    #[error("messaging endpoint `{endpoint_id}` not found in env `{env_id}`")]
122    EndpointNotFound {
123        endpoint_id: MessagingEndpointId,
124        env_id: EnvId,
125    },
126    #[error("bundle `{bundle_id}` is not deployed in env `{env_id}`")]
127    BundleNotDeployed { bundle_id: BundleId, env_id: EnvId },
128    #[error(
129        "cannot unlink bundle `{bundle_id}` from endpoint `{endpoint_id}` while it owns the \
130         welcome_flow; clear the welcome_flow first via `set-welcome-flow` to a different \
131         linked bundle, or `remove` the endpoint"
132    )]
133    WelcomeFlowOwned {
134        bundle_id: BundleId,
135        endpoint_id: MessagingEndpointId,
136    },
137    #[error(
138        "welcome_flow bundle `{bundle_id}` is not linked to endpoint `{endpoint_id}`; \
139         link it first via `link-bundle`"
140    )]
141    BundleNotLinked {
142        bundle_id: BundleId,
143        endpoint_id: MessagingEndpointId,
144    },
145    #[error(
146        "welcome_flow.pack_id `{pack_id}` does not appear in any current revision of \
147         bundle `{bundle_id}` (known: [{}])", known.join(", ")
148    )]
149    WelcomePackUnknown {
150        pack_id: String,
151        bundle_id: BundleId,
152        known: Vec<String>,
153    },
154    #[error("secret_ref `{raw}`: {message}")]
155    InvalidSecretRef { raw: String, message: String },
156    /// The backend's webhook-secret `provision` closure failed (or, on the
157    /// operator-store-server, refused — no secrets sink yet). The message
158    /// is the backend's verbatim detail; each backend owns its mapping
159    /// (LocalFS → `Conflict`, server → 501 `not-yet-implemented`).
160    #[error("{0}")]
161    SecretProvision(String),
162}
163
164// ---------------------------------------------------------------------------
165// Wire payloads / outcomes
166// ---------------------------------------------------------------------------
167
168/// Inputs to `EnvironmentMutations::add_messaging_endpoint`, and the A8
169/// `POST /environments/{env}/messaging` request body.
170///
171/// No `endpoint_id` — the storage-owning side mints it (the bundles-group
172/// `DeploymentId` precedent). No `idempotency_key` — the key rides the
173/// trait method and the A8 `Idempotency-Key` header; the engine receives
174/// it as a transform parameter because this group uses it for replay
175/// detection (see the module doc).
176#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
177pub struct AddMessagingEndpointPayload {
178    pub provider_id: String,
179    pub provider_type: String,
180    pub display_name: String,
181    /// Raw `secret://` URIs — validated into [`SecretRef`]s by the
182    /// transform so local and remote reject malformed refs identically.
183    #[serde(default)]
184    pub secret_refs: Vec<String>,
185    /// Optional caller-supplied per-endpoint webhook secret ref (raw
186    /// `secret://` URI). When present for a telegram-class endpoint the
187    /// transform validates and stamps it WITHOUT calling `provision` — the
188    /// caller owns value provisioning (remote operator stores use this so
189    /// the control-plane store never mints or custodies secret material).
190    /// `None` keeps the backend-provision path (LocalFS auto-mints). Supplying
191    /// it for a non-telegram-class endpoint is rejected.
192    #[serde(default, skip_serializing_if = "Option::is_none")]
193    pub webhook_secret_ref: Option<String>,
194    pub updated_by: String,
195}
196
197/// The A8 request body shared by the `link-bundle` and `unlink-bundle`
198/// verbs (`POST /environments/{env}/messaging/{eid}/link` / `/unlink` —
199/// the two routes diverge by URL suffix only, as the PR-3b client pinned).
200#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
201pub struct MessagingBundleLinkPayload {
202    pub bundle_id: BundleId,
203    pub updated_by: String,
204}
205
206/// Inputs to `EnvironmentMutations::set_messaging_welcome_flow`, and the
207/// A8 `POST /environments/{env}/messaging/{eid}/welcome-flow` request body
208/// (`endpoint_id` rides in the body too — the server cross-checks it
209/// against the URL segment).
210#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
211pub struct SetMessagingWelcomeFlowPayload {
212    pub endpoint_id: MessagingEndpointId,
213    pub bundle_id: BundleId,
214    pub pack_id: PackId,
215    pub flow_id: String,
216    pub updated_by: String,
217}
218
219/// The A8 `POST /environments/{env}/messaging/{eid}/rotate-secret` request
220/// body.
221#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
222pub struct RotateWebhookSecretPayload {
223    pub updated_by: String,
224    /// Optional caller-supplied NEW webhook secret ref (raw `secret://` URI).
225    /// When present the transform stamps it WITHOUT calling `provision` — the
226    /// caller has already provisioned the value in its own secrets plane
227    /// (remote operator stores use this so the control-plane store never mints
228    /// or custodies secret material, mirroring [`AddMessagingEndpointPayload::
229    /// webhook_secret_ref`]). `None` keeps the backend-provision path (LocalFS
230    /// auto-mints; a control-plane store refuses, since it cannot prove a
231    /// rotation it did not perform). The field is omitted on the wire when
232    /// `None`, so the body stays byte-compatible with pre-new-ref clients.
233    #[serde(default, skip_serializing_if = "Option::is_none")]
234    pub webhook_secret_ref: Option<String>,
235}
236
237/// What a messaging transform did, by index into
238/// `Environment.messaging_endpoints`.
239#[derive(Debug, Clone, Copy, PartialEq, Eq)]
240pub struct MessagingApplied {
241    /// Index of the affected endpoint in `Environment.messaging_endpoints`.
242    pub index: usize,
243    /// `false` = idempotent replay / no-op: the env is untouched, do not
244    /// persist (see the module-doc persist rule).
245    pub mutated: bool,
246}
247
248// ---------------------------------------------------------------------------
249// Internal helpers
250// ---------------------------------------------------------------------------
251
252/// Canonical operation names for the idem stamp. Each matches the audit-verb
253/// vocabulary (`body["audit"]["verb"]` in the operator-store-server).
254const OP_ADD: &str = "add";
255const OP_LINK_BUNDLE: &str = "link-bundle";
256const OP_UNLINK_BUNDLE: &str = "unlink-bundle";
257const OP_SET_WELCOME_FLOW: &str = "set-welcome-flow";
258const OP_ROTATE_WEBHOOK_SECRET: &str = "rotate-webhook-secret";
259
260/// Telegram-class providers need a per-endpoint webhook secret generated at
261/// creation time. Covers `"telegram"`, `"telegram.<x>"`,
262/// `"messaging.telegram"`, and `"messaging.telegram.<x>"` — strict on the
263/// dot so `"telegrambot"` and `"messaging.telegrambot"` do NOT match.
264///
265/// Public so the remote dispatch layer can reuse the canonical matcher to
266/// pre-validate that a telegram-class endpoint carries a caller-supplied
267/// `webhook_secret_ref` before a remote round-trip (rather than duplicating
268/// the rule and risking drift).
269pub fn is_telegram_class(provider_type: &str) -> bool {
270    let rest = provider_type
271        .strip_prefix("messaging.")
272        .unwrap_or(provider_type);
273    rest == "telegram" || rest.starts_with("telegram.")
274}
275
276/// Embed the idempotency key AND operation name in `updated_by` so a
277/// same-key retry surfaces as the original mutation only when the
278/// operation matches. `MessagingEndpoint` has no separate
279/// `idempotency_key` field; the encoding keeps the replay contract without
280/// bloating the spec type.
281fn format_idem_writer(updated_by: &str, op: &str, idempotency_key: &str) -> String {
282    format!("{updated_by}#idem={op}:{idempotency_key}")
283}
284
285/// Build the `#idem=<op>:<key>` suffix once so a linear scan over
286/// `messaging_endpoints` does not allocate a fresh `String` per element.
287///
288/// The `#idem=` anchor combined with full-suffix `ends_with` matching
289/// means a crafted key containing `:` cannot cross-match operations —
290/// the op portion is fixed between `#idem=` and the first `:`, and the
291/// key portion is everything after that first `:`.
292fn idem_suffix(op: &str, idempotency_key: &str) -> String {
293    format!("#idem={op}:{idempotency_key}")
294}
295
296fn carries_idem_key(endpoint: &MessagingEndpoint, idem_suffix: &str) -> bool {
297    endpoint.updated_by.ends_with(idem_suffix)
298}
299
300/// Bump generation and re-stamp the mutation metadata on an endpoint.
301fn stamp_mutation(
302    endpoint: &mut MessagingEndpoint,
303    updated_by: &str,
304    op: &str,
305    idempotency_key: &str,
306    now: DateTime<Utc>,
307) {
308    endpoint.generation = endpoint.generation.saturating_add(1);
309    endpoint.updated_at = now;
310    endpoint.updated_by = format_idem_writer(updated_by, op, idempotency_key);
311}
312
313/// Locate a messaging endpoint by id, returning
314/// [`MessagingError::EndpointNotFound`] when absent.
315fn find_endpoint_idx(
316    env: &Environment,
317    endpoint_id: MessagingEndpointId,
318) -> Result<usize, MessagingError> {
319    env.messaging_endpoints
320        .iter()
321        .position(|e| e.endpoint_id == endpoint_id)
322        .ok_or_else(|| MessagingError::EndpointNotFound {
323            endpoint_id,
324            env_id: env.environment_id.clone(),
325        })
326}
327
328/// Welcome-flow `pack_id` validation: the pack must appear in some current
329/// revision's pack_list of the bundle — unless the bundle has no
330/// deployments or no revision lists any pack yet (pre-staging authoring is
331/// allowed; the runner re-validates at dispatch).
332fn validate_welcome_pack_id(
333    env: &Environment,
334    bundle_id: &BundleId,
335    pack_id: &str,
336) -> Result<(), MessagingError> {
337    let bundles: Vec<_> = env
338        .bundles
339        .iter()
340        .filter(|b| b.bundle_id == *bundle_id)
341        .collect();
342    if bundles.is_empty() {
343        return Ok(());
344    }
345    let mut saw_any_pack = false;
346    let mut known_packs: Vec<String> = Vec::new();
347    for bundle in bundles {
348        for rev_id in &bundle.current_revisions {
349            let Some(rev) = env.revisions.iter().find(|r| r.revision_id == *rev_id) else {
350                continue;
351            };
352            for entry in &rev.pack_list {
353                saw_any_pack = true;
354                if entry.pack_id.as_str() == pack_id {
355                    return Ok(());
356                }
357                known_packs.push(entry.pack_id.as_str().to_string());
358            }
359        }
360    }
361    if !saw_any_pack {
362        return Ok(());
363    }
364    known_packs.sort();
365    known_packs.dedup();
366    Err(MessagingError::WelcomePackUnknown {
367        pack_id: pack_id.to_string(),
368        bundle_id: bundle_id.clone(),
369        known: known_packs,
370    })
371}
372
373// ---------------------------------------------------------------------------
374// Transforms
375// ---------------------------------------------------------------------------
376
377/// Add a messaging endpoint. Rejects with
378/// [`MessagingError::EndpointAlreadyExists`] when the
379/// `(provider_type, provider_id)` pair is already present, and with
380/// [`MessagingError::IdempotencyKeyReuse`] when the key was already used
381/// for a different endpoint identity. Idempotent on same-key
382/// same-identity replay (`mutated == false`).
383///
384/// Validation order is pinned (and tested): replay check → duplicate check
385/// → `secret_refs` parse → `provision` (telegram-class only) → push. A
386/// malformed ref therefore never leaves an orphan secret in the backend's
387/// sink, and a replay never re-provisions.
388pub fn add_messaging_endpoint(
389    env: &mut Environment,
390    payload: AddMessagingEndpointPayload,
391    endpoint_id: MessagingEndpointId,
392    idempotency_key: &IdempotencyKey,
393    now: DateTime<Utc>,
394    provision: impl FnOnce(Option<&SecretRef>) -> Result<SecretRef, MessagingError>,
395) -> Result<MessagingApplied, MessagingError> {
396    let suffix = idem_suffix(OP_ADD, idempotency_key.as_str());
397    // Idempotent replay: re-running with the same key AND same op
398    // returns the previously-created endpoint iff the payload's instance
399    // identity matches what was stored. A key stamped by a different op
400    // (link-bundle, rotate, etc.) does NOT satisfy this check — the add
401    // proceeds as a fresh mutation and the duplicate-identity check
402    // below still guards collisions.
403    if let Some(idx) = env
404        .messaging_endpoints
405        .iter()
406        .position(|e| carries_idem_key(e, &suffix))
407    {
408        let prev = &env.messaging_endpoints[idx];
409        if prev.provider_type == payload.provider_type && prev.provider_id == payload.provider_id {
410            return Ok(MessagingApplied {
411                index: idx,
412                mutated: false,
413            });
414        }
415        return Err(MessagingError::IdempotencyKeyReuse {
416            key: idempotency_key.as_str().to_string(),
417            provider_type: prev.provider_type.clone(),
418            provider_id: prev.provider_id.clone(),
419            env_id: env.environment_id.clone(),
420        });
421    }
422    if env
423        .messaging_endpoints
424        .iter()
425        .any(|e| e.provider_type == payload.provider_type && e.provider_id == payload.provider_id)
426    {
427        return Err(MessagingError::EndpointAlreadyExists {
428            provider_type: payload.provider_type,
429            provider_id: payload.provider_id,
430            env_id: env.environment_id.clone(),
431        });
432    }
433    // Validate secret_refs BEFORE provisioning the webhook secret so a
434    // malformed ref does not leave an orphan secret in the backend's sink.
435    let secret_refs: Vec<SecretRef> = payload
436        .secret_refs
437        .iter()
438        .map(|r| {
439            SecretRef::try_new(r).map_err(|e| MessagingError::InvalidSecretRef {
440                raw: r.clone(),
441                message: e.to_string(),
442            })
443        })
444        .collect::<Result<_, _>>()?;
445    let webhook_secret_ref = match (
446        is_telegram_class(&payload.provider_type),
447        payload.webhook_secret_ref.as_deref(),
448    ) {
449        // Caller-supplied ref: validate (telegram-class gate + parse) and stamp
450        // it, never calling the backend sink. The caller owns value
451        // provisioning (remote operator stores ship the ref so the
452        // control-plane store never mints or custodies secret material); a ref
453        // on a non-telegram endpoint is a caller error.
454        (is_telegram, Some(raw)) => Some(validate_caller_webhook_ref(is_telegram, raw)?),
455        // Telegram-class with no supplied ref: the backend provisions
456        // (LocalFS auto-mints + stores; a control-plane store refuses).
457        (true, None) => Some(provision(None)?),
458        (false, None) => None,
459    };
460    env.messaging_endpoints.push(MessagingEndpoint {
461        schema: SchemaVersion::new(SchemaVersion::MESSAGING_ENDPOINT_V1),
462        env_id: env.environment_id.clone(),
463        endpoint_id,
464        provider_id: payload.provider_id,
465        provider_type: payload.provider_type,
466        display_name: payload.display_name,
467        secret_refs,
468        webhook_secret_ref,
469        linked_bundles: Vec::new(),
470        welcome_flow: None,
471        generation: 0,
472        created_at: now,
473        updated_at: now,
474        updated_by: format_idem_writer(&payload.updated_by, OP_ADD, idempotency_key.as_str()),
475    });
476    Ok(MessagingApplied {
477        index: env.messaging_endpoints.len() - 1,
478        mutated: true,
479    })
480}
481
482/// Link a bundle to an existing messaging endpoint. Idempotent when the
483/// bundle is already linked (`mutated == false`). Rejects when the
484/// endpoint or bundle is missing.
485pub fn link_messaging_bundle(
486    env: &mut Environment,
487    endpoint_id: MessagingEndpointId,
488    bundle_id: BundleId,
489    updated_by: &str,
490    idempotency_key: &IdempotencyKey,
491    now: DateTime<Utc>,
492) -> Result<MessagingApplied, MessagingError> {
493    let idx = find_endpoint_idx(env, endpoint_id)?;
494    if !env.bundles.iter().any(|b| b.bundle_id == bundle_id) {
495        return Err(MessagingError::BundleNotDeployed {
496            bundle_id,
497            env_id: env.environment_id.clone(),
498        });
499    }
500    if env.messaging_endpoints[idx]
501        .linked_bundles
502        .contains(&bundle_id)
503    {
504        return Ok(MessagingApplied {
505            index: idx,
506            mutated: false,
507        });
508    }
509    env.messaging_endpoints[idx].linked_bundles.push(bundle_id);
510    stamp_mutation(
511        &mut env.messaging_endpoints[idx],
512        updated_by,
513        OP_LINK_BUNDLE,
514        idempotency_key.as_str(),
515        now,
516    );
517    Ok(MessagingApplied {
518        index: idx,
519        mutated: true,
520    })
521}
522
523/// Unlink a bundle from an existing messaging endpoint. Idempotent when
524/// the bundle is not linked (`mutated == false`). Rejects with
525/// [`MessagingError::WelcomeFlowOwned`] if the bundle owns the endpoint's
526/// `welcome_flow`.
527pub fn unlink_messaging_bundle(
528    env: &mut Environment,
529    endpoint_id: MessagingEndpointId,
530    bundle_id: BundleId,
531    updated_by: &str,
532    idempotency_key: &IdempotencyKey,
533    now: DateTime<Utc>,
534) -> Result<MessagingApplied, MessagingError> {
535    let idx = find_endpoint_idx(env, endpoint_id)?;
536    let Some(bidx) = env.messaging_endpoints[idx]
537        .linked_bundles
538        .iter()
539        .position(|b| b == &bundle_id)
540    else {
541        // Idempotent: unlinking a bundle that isn't linked is a no-op.
542        return Ok(MessagingApplied {
543            index: idx,
544            mutated: false,
545        });
546    };
547    if let Some(welcome) = &env.messaging_endpoints[idx].welcome_flow
548        && welcome.bundle_id == bundle_id
549    {
550        return Err(MessagingError::WelcomeFlowOwned {
551            bundle_id,
552            endpoint_id,
553        });
554    }
555    env.messaging_endpoints[idx].linked_bundles.remove(bidx);
556    stamp_mutation(
557        &mut env.messaging_endpoints[idx],
558        updated_by,
559        OP_UNLINK_BUNDLE,
560        idempotency_key.as_str(),
561        now,
562    );
563    Ok(MessagingApplied {
564        index: idx,
565        mutated: true,
566    })
567}
568
569/// Set the welcome flow on a messaging endpoint. Rejects with
570/// [`MessagingError::BundleNotLinked`] when the bundle is not linked, and
571/// [`MessagingError::WelcomePackUnknown`] when `pack_id` does not appear
572/// in any current revision's pack_list. Idempotent when the same welcome
573/// flow ref is already set (`mutated == false`).
574pub fn set_messaging_welcome_flow(
575    env: &mut Environment,
576    payload: SetMessagingWelcomeFlowPayload,
577    idempotency_key: &IdempotencyKey,
578    now: DateTime<Utc>,
579) -> Result<MessagingApplied, MessagingError> {
580    let idx = find_endpoint_idx(env, payload.endpoint_id)?;
581    if !env.messaging_endpoints[idx]
582        .linked_bundles
583        .contains(&payload.bundle_id)
584    {
585        return Err(MessagingError::BundleNotLinked {
586            bundle_id: payload.bundle_id,
587            endpoint_id: payload.endpoint_id,
588        });
589    }
590    validate_welcome_pack_id(env, &payload.bundle_id, payload.pack_id.as_str())?;
591    let new_welcome = WelcomeFlowRef {
592        bundle_id: payload.bundle_id,
593        pack_id: payload.pack_id,
594        flow_id: payload.flow_id,
595    };
596    if env.messaging_endpoints[idx].welcome_flow.as_ref() == Some(&new_welcome) {
597        return Ok(MessagingApplied {
598            index: idx,
599            mutated: false,
600        });
601    }
602    env.messaging_endpoints[idx].welcome_flow = Some(new_welcome);
603    stamp_mutation(
604        &mut env.messaging_endpoints[idx],
605        &payload.updated_by,
606        OP_SET_WELCOME_FLOW,
607        idempotency_key.as_str(),
608        now,
609    );
610    Ok(MessagingApplied {
611        index: idx,
612        mutated: true,
613    })
614}
615
616/// Remove a messaging endpoint by id. Idempotent when the endpoint is
617/// already absent — returns whether the env was actually mutated (the
618/// only verb in this group that cannot fail).
619pub fn remove_messaging_endpoint(env: &mut Environment, endpoint_id: MessagingEndpointId) -> bool {
620    match env
621        .messaging_endpoints
622        .iter()
623        .position(|e| e.endpoint_id == endpoint_id)
624    {
625        Some(idx) => {
626            env.messaging_endpoints.remove(idx);
627            true
628        }
629        None => false,
630    }
631}
632
633/// Validate a caller-supplied webhook secret ref. A `webhook_secret_ref` is
634/// only meaningful for a telegram-class endpoint (rejected otherwise) and must
635/// parse as a [`SecretRef`]. Shared by `add` and the new-ref `rotate` so the
636/// telegram-class gate, the parse, and the error message live in one place.
637fn validate_caller_webhook_ref(is_telegram: bool, raw: &str) -> Result<SecretRef, MessagingError> {
638    if !is_telegram {
639        return Err(MessagingError::InvalidSecretRef {
640            raw: raw.to_string(),
641            message: "webhook_secret_ref is only valid for telegram-class providers".to_string(),
642        });
643    }
644    SecretRef::try_new(raw).map_err(|e| MessagingError::InvalidSecretRef {
645        raw: raw.to_string(),
646        message: e.to_string(),
647    })
648}
649
650/// Rotate the webhook secret for a messaging endpoint.
651///
652/// With `new_ref = Some(raw)` the caller supplies a NEW ref already
653/// provisioned in its own secrets plane: it is validated (the telegram-class
654/// gate and ref parse that `add` enforces) and stamped WITHOUT calling
655/// `provision`. With `new_ref = None` the backend `provision` mints a value
656/// (receiving the existing ref so an already-decoupled endpoint keeps its URI)
657/// and the returned ref is stamped. Idempotent on same-key replay
658/// (`mutated == false`, neither path runs — a replay must never overwrite the
659/// live secret nor re-validate the ref).
660pub fn rotate_messaging_webhook_secret(
661    env: &mut Environment,
662    endpoint_id: MessagingEndpointId,
663    updated_by: &str,
664    idempotency_key: &IdempotencyKey,
665    now: DateTime<Utc>,
666    new_ref: Option<&str>,
667    provision: impl FnOnce(Option<&SecretRef>) -> Result<SecretRef, MessagingError>,
668) -> Result<MessagingApplied, MessagingError> {
669    let idx = find_endpoint_idx(env, endpoint_id)?;
670    let suffix = idem_suffix(OP_ROTATE_WEBHOOK_SECRET, idempotency_key.as_str());
671    if carries_idem_key(&env.messaging_endpoints[idx], &suffix) {
672        return Ok(MessagingApplied {
673            index: idx,
674            mutated: false,
675        });
676    }
677    let secret_ref = match new_ref {
678        Some(raw) => validate_caller_webhook_ref(
679            is_telegram_class(&env.messaging_endpoints[idx].provider_type),
680            raw,
681        )?,
682        None => provision(env.messaging_endpoints[idx].webhook_secret_ref.as_ref())?,
683    };
684    env.messaging_endpoints[idx].webhook_secret_ref = Some(secret_ref);
685    stamp_mutation(
686        &mut env.messaging_endpoints[idx],
687        updated_by,
688        OP_ROTATE_WEBHOOK_SECRET,
689        idempotency_key.as_str(),
690        now,
691    );
692    Ok(MessagingApplied {
693        index: idx,
694        mutated: true,
695    })
696}
697
698#[cfg(test)]
699mod tests {
700    use super::*;
701    use crate::bundle_deployment::{
702        BundleDeployment, BundleDeploymentStatus, RouteBinding, TenantSelector,
703    };
704    use crate::engine::fresh_environment;
705    use crate::environment::EnvironmentHostConfig;
706    use crate::ids::{CustomerId, RevisionId};
707    use crate::retention::{HealthStatus, RetentionPolicy, RevocationConfig};
708    use crate::revision::{PackListEntry, Revision, RevisionLifecycle};
709    use std::path::PathBuf;
710
711    fn env_id() -> EnvId {
712        EnvId::try_from("local").unwrap()
713    }
714
715    fn minimal_env() -> Environment {
716        fresh_environment(
717            &env_id(),
718            "Local".to_string(),
719            EnvironmentHostConfig {
720                env_id: env_id(),
721                region: None,
722                tenant_org_id: None,
723                listen_addr: None,
724                public_base_url: None,
725                gui_enabled: None,
726            },
727            RevocationConfig::default(),
728            RetentionPolicy::default(),
729            HealthStatus::default(),
730        )
731    }
732
733    fn fixed_now() -> DateTime<Utc> {
734        "2026-06-12T00:00:00Z".parse().unwrap()
735    }
736
737    fn key(raw: &str) -> IdempotencyKey {
738        IdempotencyKey::new(raw).unwrap()
739    }
740
741    fn add_payload(provider_type: &str, provider_id: &str) -> AddMessagingEndpointPayload {
742        AddMessagingEndpointPayload {
743            provider_id: provider_id.to_string(),
744            provider_type: provider_type.to_string(),
745            display_name: format!("{provider_type} {provider_id}"),
746            secret_refs: Vec::new(),
747            webhook_secret_ref: None,
748            updated_by: "tester".to_string(),
749        }
750    }
751
752    fn add_payload_with_webhook_ref(
753        provider_type: &str,
754        provider_id: &str,
755        webhook_secret_ref: &str,
756    ) -> AddMessagingEndpointPayload {
757        AddMessagingEndpointPayload {
758            webhook_secret_ref: Some(webhook_secret_ref.to_string()),
759            ..add_payload(provider_type, provider_id)
760        }
761    }
762
763    /// A provision closure that must never run.
764    fn no_provision(_: Option<&SecretRef>) -> Result<SecretRef, MessagingError> {
765        panic!("provision must not be called on this path");
766    }
767
768    fn fixed_ref() -> SecretRef {
769        SecretRef::try_new("secret://local/default/_/messaging-x/webhook_secret").unwrap()
770    }
771
772    fn deployed_bundle(env: &mut Environment, bundle: &str) -> BundleId {
773        let bundle_id = BundleId::new(bundle);
774        env.bundles.push(BundleDeployment {
775            schema: SchemaVersion::new(SchemaVersion::BUNDLE_DEPLOYMENT_V1),
776            deployment_id: crate::ids::DeploymentId::new(),
777            env_id: env_id(),
778            bundle_id: bundle_id.clone(),
779            customer_id: CustomerId::new("cust"),
780            status: BundleDeploymentStatus::Active,
781            current_revisions: Vec::new(),
782            route_binding: RouteBinding {
783                hosts: Vec::new(),
784                path_prefixes: Vec::new(),
785                tenant_selector: TenantSelector {
786                    tenant: "default".to_string(),
787                    team: "default".to_string(),
788                },
789            },
790            revenue_share: Vec::new(),
791            revenue_policy_ref: PathBuf::new(),
792            usage: None,
793            created_at: fixed_now(),
794            authorization_ref: PathBuf::from("auth.json"),
795            config_overrides: Default::default(),
796        });
797        bundle_id
798    }
799
800    fn added(env: &mut Environment, provider_type: &str, provider_id: &str, k: &str) -> usize {
801        add_messaging_endpoint(
802            env,
803            add_payload(provider_type, provider_id),
804            MessagingEndpointId::new(),
805            &key(k),
806            fixed_now(),
807            no_provision,
808        )
809        .expect("add")
810        .index
811    }
812
813    // --- add -----------------------------------------------------------------
814
815    #[test]
816    fn add_non_telegram_skips_provision_and_pushes() {
817        let mut env = minimal_env();
818        let applied = add_messaging_endpoint(
819            &mut env,
820            add_payload("teams", "legal"),
821            MessagingEndpointId::new(),
822            &key("k1"),
823            fixed_now(),
824            no_provision,
825        )
826        .unwrap();
827        assert!(applied.mutated);
828        let ep = &env.messaging_endpoints[applied.index];
829        assert_eq!(ep.provider_type, "teams");
830        assert_eq!(ep.updated_by, "tester#idem=add:k1");
831        assert_eq!(ep.generation, 0);
832        assert!(ep.webhook_secret_ref.is_none());
833    }
834
835    #[test]
836    fn add_telegram_class_provisions_with_no_existing_ref() {
837        let mut env = minimal_env();
838        let applied = add_messaging_endpoint(
839            &mut env,
840            add_payload("telegram", "bot-a"),
841            MessagingEndpointId::new(),
842            &key("k1"),
843            fixed_now(),
844            |existing| {
845                assert!(existing.is_none(), "fresh endpoint has no existing ref");
846                Ok(fixed_ref())
847            },
848        )
849        .unwrap();
850        assert_eq!(
851            env.messaging_endpoints[applied.index].webhook_secret_ref,
852            Some(fixed_ref())
853        );
854    }
855
856    #[test]
857    fn add_telegram_class_with_supplied_ref_uses_it_without_provision() {
858        let mut env = minimal_env();
859        let supplied = "secret://local/default/_/messaging-byo/webhook_secret";
860        // `no_provision` panics if called — proves the supplied ref bypasses
861        // the backend sink entirely (the remote operator-store path).
862        let applied = add_messaging_endpoint(
863            &mut env,
864            add_payload_with_webhook_ref("telegram", "bot-a", supplied),
865            MessagingEndpointId::new(),
866            &key("k1"),
867            fixed_now(),
868            no_provision,
869        )
870        .unwrap();
871        assert!(applied.mutated);
872        assert_eq!(
873            env.messaging_endpoints[applied.index]
874                .webhook_secret_ref
875                .as_ref()
876                .map(|r| r.as_str()),
877            Some(supplied)
878        );
879    }
880
881    #[test]
882    fn add_telegram_class_with_malformed_supplied_ref_is_rejected() {
883        let mut env = minimal_env();
884        let err = add_messaging_endpoint(
885            &mut env,
886            add_payload_with_webhook_ref("telegram", "bot-a", "not-a-secret-uri"),
887            MessagingEndpointId::new(),
888            &key("k1"),
889            fixed_now(),
890            no_provision,
891        )
892        .unwrap_err();
893        assert!(matches!(err, MessagingError::InvalidSecretRef { .. }));
894        assert!(
895            env.messaging_endpoints.is_empty(),
896            "a rejected add must not push"
897        );
898    }
899
900    #[test]
901    fn add_non_telegram_with_supplied_ref_is_rejected() {
902        let mut env = minimal_env();
903        let err = add_messaging_endpoint(
904            &mut env,
905            add_payload_with_webhook_ref(
906                "teams",
907                "legal",
908                "secret://local/default/_/messaging-x/webhook_secret",
909            ),
910            MessagingEndpointId::new(),
911            &key("k1"),
912            fixed_now(),
913            no_provision,
914        )
915        .unwrap_err();
916        match err {
917            MessagingError::InvalidSecretRef { ref message, .. } => {
918                assert!(
919                    message.contains("only valid for telegram-class"),
920                    "got {err:?}"
921                );
922            }
923            other => panic!("expected InvalidSecretRef, got {other:?}"),
924        }
925        assert!(env.messaging_endpoints.is_empty());
926    }
927
928    #[test]
929    fn add_same_key_same_identity_replays_without_mutation() {
930        let mut env = minimal_env();
931        let idx = added(&mut env, "teams", "legal", "k-replay");
932        let before = env.clone();
933        let applied = add_messaging_endpoint(
934            &mut env,
935            add_payload("teams", "legal"),
936            MessagingEndpointId::new(),
937            &key("k-replay"),
938            fixed_now(),
939            no_provision,
940        )
941        .unwrap();
942        assert_eq!(applied.index, idx);
943        assert!(!applied.mutated);
944        assert_eq!(env, before, "replay must leave the env untouched");
945    }
946
947    #[test]
948    fn add_same_key_different_identity_is_key_reuse() {
949        let mut env = minimal_env();
950        added(&mut env, "teams", "legal", "k-shared");
951        let err = add_messaging_endpoint(
952            &mut env,
953            add_payload("slack", "ops"),
954            MessagingEndpointId::new(),
955            &key("k-shared"),
956            fixed_now(),
957            no_provision,
958        )
959        .unwrap_err();
960        assert!(matches!(err, MessagingError::IdempotencyKeyReuse { .. }));
961        assert_eq!(
962            err.to_string(),
963            "idempotency key `k-shared` already used to add `teams`/`legal` in env `local`; \
964             pass a fresh key"
965        );
966    }
967
968    #[test]
969    fn add_duplicate_identity_rejected() {
970        let mut env = minimal_env();
971        added(&mut env, "teams", "legal", "k1");
972        let err = add_messaging_endpoint(
973            &mut env,
974            add_payload("teams", "legal"),
975            MessagingEndpointId::new(),
976            &key("k2"),
977            fixed_now(),
978            no_provision,
979        )
980        .unwrap_err();
981        assert_eq!(
982            err.to_string(),
983            "messaging endpoint with provider_type=`teams` provider_id=`legal` already \
984             exists in env `local`"
985        );
986    }
987
988    #[test]
989    fn add_invalid_secret_ref_rejected_before_provision() {
990        let mut env = minimal_env();
991        let mut payload = add_payload("telegram", "bot-a");
992        payload.secret_refs = vec!["not-a-ref".to_string()];
993        // `no_provision` panics if called — passing it pins the ordering.
994        let err = add_messaging_endpoint(
995            &mut env,
996            payload,
997            MessagingEndpointId::new(),
998            &key("k1"),
999            fixed_now(),
1000            no_provision,
1001        )
1002        .unwrap_err();
1003        assert!(matches!(err, MessagingError::InvalidSecretRef { .. }));
1004        assert!(env.messaging_endpoints.is_empty());
1005    }
1006
1007    #[test]
1008    fn add_provision_failure_leaves_env_untouched() {
1009        let mut env = minimal_env();
1010        let err = add_messaging_endpoint(
1011            &mut env,
1012            add_payload("telegram", "bot-a"),
1013            MessagingEndpointId::new(),
1014            &key("k1"),
1015            fixed_now(),
1016            |_| Err(MessagingError::SecretProvision("sink down".to_string())),
1017        )
1018        .unwrap_err();
1019        assert_eq!(err.to_string(), "sink down");
1020        assert!(env.messaging_endpoints.is_empty());
1021    }
1022
1023    // --- link / unlink ---------------------------------------------------------
1024
1025    #[test]
1026    fn link_appends_and_stamps() {
1027        let mut env = minimal_env();
1028        let bundle_id = deployed_bundle(&mut env, "legal-pack");
1029        let idx = added(&mut env, "teams", "legal", "k1");
1030        let eid = env.messaging_endpoints[idx].endpoint_id;
1031        let applied = link_messaging_bundle(
1032            &mut env,
1033            eid,
1034            bundle_id.clone(),
1035            "op",
1036            &key("k2"),
1037            fixed_now(),
1038        )
1039        .unwrap();
1040        assert!(applied.mutated);
1041        let ep = &env.messaging_endpoints[applied.index];
1042        assert_eq!(ep.linked_bundles, vec![bundle_id]);
1043        assert_eq!(ep.generation, 1);
1044        assert_eq!(ep.updated_by, "op#idem=link-bundle:k2");
1045    }
1046
1047    #[test]
1048    fn link_unknown_endpoint_not_found() {
1049        let mut env = minimal_env();
1050        deployed_bundle(&mut env, "legal-pack");
1051        let ghost = MessagingEndpointId::new();
1052        let err = link_messaging_bundle(
1053            &mut env,
1054            ghost,
1055            BundleId::new("legal-pack"),
1056            "op",
1057            &key("k1"),
1058            fixed_now(),
1059        )
1060        .unwrap_err();
1061        assert_eq!(
1062            err.to_string(),
1063            format!("messaging endpoint `{ghost}` not found in env `local`")
1064        );
1065    }
1066
1067    #[test]
1068    fn link_undeployed_bundle_rejected() {
1069        let mut env = minimal_env();
1070        let idx = added(&mut env, "teams", "legal", "k1");
1071        let eid = env.messaging_endpoints[idx].endpoint_id;
1072        let err = link_messaging_bundle(
1073            &mut env,
1074            eid,
1075            BundleId::new("ghost-pack"),
1076            "op",
1077            &key("k2"),
1078            fixed_now(),
1079        )
1080        .unwrap_err();
1081        assert_eq!(
1082            err.to_string(),
1083            "bundle `ghost-pack` is not deployed in env `local`"
1084        );
1085    }
1086
1087    #[test]
1088    fn link_already_linked_is_noop() {
1089        let mut env = minimal_env();
1090        let bundle_id = deployed_bundle(&mut env, "legal-pack");
1091        let idx = added(&mut env, "teams", "legal", "k1");
1092        let eid = env.messaging_endpoints[idx].endpoint_id;
1093        link_messaging_bundle(
1094            &mut env,
1095            eid,
1096            bundle_id.clone(),
1097            "op",
1098            &key("k2"),
1099            fixed_now(),
1100        )
1101        .unwrap();
1102        let before = env.clone();
1103        let applied =
1104            link_messaging_bundle(&mut env, eid, bundle_id, "op", &key("k3"), fixed_now()).unwrap();
1105        assert!(!applied.mutated);
1106        assert_eq!(env, before);
1107    }
1108
1109    #[test]
1110    fn unlink_welcome_owner_rejected() {
1111        let mut env = minimal_env();
1112        let bundle_id = deployed_bundle(&mut env, "legal-pack");
1113        let idx = added(&mut env, "teams", "legal", "k1");
1114        let eid = env.messaging_endpoints[idx].endpoint_id;
1115        link_messaging_bundle(
1116            &mut env,
1117            eid,
1118            bundle_id.clone(),
1119            "op",
1120            &key("k2"),
1121            fixed_now(),
1122        )
1123        .unwrap();
1124        set_messaging_welcome_flow(
1125            &mut env,
1126            SetMessagingWelcomeFlowPayload {
1127                endpoint_id: eid,
1128                bundle_id: bundle_id.clone(),
1129                pack_id: PackId::new("welcome-pack"),
1130                flow_id: "hello".to_string(),
1131                updated_by: "op".to_string(),
1132            },
1133            &key("k3"),
1134            fixed_now(),
1135        )
1136        .unwrap();
1137        let err = unlink_messaging_bundle(&mut env, eid, bundle_id, "op", &key("k4"), fixed_now())
1138            .unwrap_err();
1139        assert!(matches!(err, MessagingError::WelcomeFlowOwned { .. }));
1140    }
1141
1142    #[test]
1143    fn unlink_not_linked_is_noop() {
1144        let mut env = minimal_env();
1145        let bundle_id = deployed_bundle(&mut env, "legal-pack");
1146        let idx = added(&mut env, "teams", "legal", "k1");
1147        let eid = env.messaging_endpoints[idx].endpoint_id;
1148        let before = env.clone();
1149        let applied =
1150            unlink_messaging_bundle(&mut env, eid, bundle_id, "op", &key("k2"), fixed_now())
1151                .unwrap();
1152        assert!(!applied.mutated);
1153        assert_eq!(env, before);
1154    }
1155
1156    #[test]
1157    fn unlink_removes_and_stamps() {
1158        let mut env = minimal_env();
1159        let bundle_id = deployed_bundle(&mut env, "legal-pack");
1160        let idx = added(&mut env, "teams", "legal", "k1");
1161        let eid = env.messaging_endpoints[idx].endpoint_id;
1162        link_messaging_bundle(
1163            &mut env,
1164            eid,
1165            bundle_id.clone(),
1166            "op",
1167            &key("k2"),
1168            fixed_now(),
1169        )
1170        .unwrap();
1171        let applied =
1172            unlink_messaging_bundle(&mut env, eid, bundle_id, "op", &key("k3"), fixed_now())
1173                .unwrap();
1174        assert!(applied.mutated);
1175        let ep = &env.messaging_endpoints[applied.index];
1176        assert!(ep.linked_bundles.is_empty());
1177        assert_eq!(ep.generation, 2);
1178    }
1179
1180    // --- set-welcome-flow ------------------------------------------------------
1181
1182    #[test]
1183    fn welcome_flow_requires_linked_bundle() {
1184        let mut env = minimal_env();
1185        deployed_bundle(&mut env, "legal-pack");
1186        let idx = added(&mut env, "teams", "legal", "k1");
1187        let eid = env.messaging_endpoints[idx].endpoint_id;
1188        let err = set_messaging_welcome_flow(
1189            &mut env,
1190            SetMessagingWelcomeFlowPayload {
1191                endpoint_id: eid,
1192                bundle_id: BundleId::new("legal-pack"),
1193                pack_id: PackId::new("welcome-pack"),
1194                flow_id: "hello".to_string(),
1195                updated_by: "op".to_string(),
1196            },
1197            &key("k2"),
1198            fixed_now(),
1199        )
1200        .unwrap_err();
1201        assert!(matches!(err, MessagingError::BundleNotLinked { .. }));
1202    }
1203
1204    #[test]
1205    fn welcome_flow_unknown_pack_rejected_with_known_list() {
1206        let mut env = minimal_env();
1207        let bundle_id = deployed_bundle(&mut env, "legal-pack");
1208        // Give the deployment a current revision listing a different pack.
1209        let rev_id = RevisionId::new();
1210        env.bundles[0].current_revisions.push(rev_id);
1211        env.revisions.push(Revision {
1212            schema: SchemaVersion::new(SchemaVersion::REVISION_V1),
1213            revision_id: rev_id,
1214            env_id: env_id(),
1215            bundle_id: bundle_id.clone(),
1216            deployment_id: env.bundles[0].deployment_id,
1217            sequence: 1,
1218            created_at: fixed_now(),
1219            bundle_digest: "sha256:deadbeef".to_string(),
1220            bundle_source_uri: None,
1221            pack_list: vec![PackListEntry {
1222                pack_id: PackId::new("known-pack"),
1223                version: "1.0.0".parse().unwrap(),
1224                digest: "sha256:cafe".to_string(),
1225                source_uri: None,
1226            }],
1227            pack_list_lock_ref: PathBuf::from("pack-list.lock"),
1228            pack_config_refs: Vec::new(),
1229            config_digest: "sha256:cafe".to_string(),
1230            signature_sidecar_ref: PathBuf::from("rev.sig"),
1231            lifecycle: RevisionLifecycle::Ready,
1232            staged_at: None,
1233            warmed_at: None,
1234            drain_seconds: 0,
1235            abort_metrics: Vec::new(),
1236        });
1237        let idx = added(&mut env, "teams", "legal", "k1");
1238        let eid = env.messaging_endpoints[idx].endpoint_id;
1239        link_messaging_bundle(
1240            &mut env,
1241            eid,
1242            bundle_id.clone(),
1243            "op",
1244            &key("k2"),
1245            fixed_now(),
1246        )
1247        .unwrap();
1248        let err = set_messaging_welcome_flow(
1249            &mut env,
1250            SetMessagingWelcomeFlowPayload {
1251                endpoint_id: eid,
1252                bundle_id,
1253                pack_id: PackId::new("ghost-pack"),
1254                flow_id: "hello".to_string(),
1255                updated_by: "op".to_string(),
1256            },
1257            &key("k3"),
1258            fixed_now(),
1259        )
1260        .unwrap_err();
1261        assert_eq!(
1262            err.to_string(),
1263            "welcome_flow.pack_id `ghost-pack` does not appear in any current revision of \
1264             bundle `legal-pack` (known: [known-pack])"
1265        );
1266    }
1267
1268    #[test]
1269    fn welcome_flow_same_ref_is_noop() {
1270        let mut env = minimal_env();
1271        let bundle_id = deployed_bundle(&mut env, "legal-pack");
1272        let idx = added(&mut env, "teams", "legal", "k1");
1273        let eid = env.messaging_endpoints[idx].endpoint_id;
1274        link_messaging_bundle(
1275            &mut env,
1276            eid,
1277            bundle_id.clone(),
1278            "op",
1279            &key("k2"),
1280            fixed_now(),
1281        )
1282        .unwrap();
1283        let payload = SetMessagingWelcomeFlowPayload {
1284            endpoint_id: eid,
1285            bundle_id,
1286            pack_id: PackId::new("welcome-pack"),
1287            flow_id: "hello".to_string(),
1288            updated_by: "op".to_string(),
1289        };
1290        let first =
1291            set_messaging_welcome_flow(&mut env, payload.clone(), &key("k3"), fixed_now()).unwrap();
1292        assert!(first.mutated);
1293        let before = env.clone();
1294        let second =
1295            set_messaging_welcome_flow(&mut env, payload, &key("k4"), fixed_now()).unwrap();
1296        assert!(!second.mutated);
1297        assert_eq!(env, before);
1298    }
1299
1300    // --- remove ------------------------------------------------------------------
1301
1302    #[test]
1303    fn remove_present_then_absent() {
1304        let mut env = minimal_env();
1305        let idx = added(&mut env, "teams", "legal", "k1");
1306        let eid = env.messaging_endpoints[idx].endpoint_id;
1307        assert!(remove_messaging_endpoint(&mut env, eid));
1308        assert!(env.messaging_endpoints.is_empty());
1309        assert!(!remove_messaging_endpoint(&mut env, eid));
1310    }
1311
1312    // --- rotate ------------------------------------------------------------------
1313
1314    #[test]
1315    fn rotate_passes_existing_ref_and_stamps() {
1316        let mut env = minimal_env();
1317        let applied = add_messaging_endpoint(
1318            &mut env,
1319            add_payload("telegram", "bot-a"),
1320            MessagingEndpointId::new(),
1321            &key("k1"),
1322            fixed_now(),
1323            |_| Ok(fixed_ref()),
1324        )
1325        .unwrap();
1326        let eid = env.messaging_endpoints[applied.index].endpoint_id;
1327        let rotated = rotate_messaging_webhook_secret(
1328            &mut env,
1329            eid,
1330            "op",
1331            &key("k2"),
1332            fixed_now(),
1333            None,
1334            |existing| {
1335                assert_eq!(existing, Some(&fixed_ref()), "existing ref must be reused");
1336                Ok(fixed_ref())
1337            },
1338        )
1339        .unwrap();
1340        assert!(rotated.mutated);
1341        assert_eq!(env.messaging_endpoints[rotated.index].generation, 1);
1342    }
1343
1344    #[test]
1345    fn rotate_same_key_replay_skips_provision() {
1346        let mut env = minimal_env();
1347        let applied = add_messaging_endpoint(
1348            &mut env,
1349            add_payload("telegram", "bot-a"),
1350            MessagingEndpointId::new(),
1351            &key("k1"),
1352            fixed_now(),
1353            |_| Ok(fixed_ref()),
1354        )
1355        .unwrap();
1356        let eid = env.messaging_endpoints[applied.index].endpoint_id;
1357        // First rotate with key "k-rotate" — must provision.
1358        rotate_messaging_webhook_secret(
1359            &mut env,
1360            eid,
1361            "op",
1362            &key("k-rotate"),
1363            fixed_now(),
1364            None,
1365            |existing| {
1366                assert_eq!(existing, Some(&fixed_ref()));
1367                Ok(fixed_ref())
1368            },
1369        )
1370        .unwrap();
1371        // Same-op same-key replay — provision must NOT run.
1372        let before = env.clone();
1373        let rotated = rotate_messaging_webhook_secret(
1374            &mut env,
1375            eid,
1376            "op",
1377            &key("k-rotate"),
1378            fixed_now(),
1379            None,
1380            no_provision,
1381        )
1382        .unwrap();
1383        assert!(!rotated.mutated);
1384        assert_eq!(env, before);
1385    }
1386
1387    #[test]
1388    fn rotate_unknown_endpoint_not_found() {
1389        let mut env = minimal_env();
1390        let ghost = MessagingEndpointId::new();
1391        let err = rotate_messaging_webhook_secret(
1392            &mut env,
1393            ghost,
1394            "op",
1395            &key("k1"),
1396            fixed_now(),
1397            None,
1398            no_provision,
1399        )
1400        .unwrap_err();
1401        assert!(matches!(err, MessagingError::EndpointNotFound { .. }));
1402    }
1403
1404    #[test]
1405    fn rotate_new_ref_validates_and_stamps_without_provision() {
1406        let mut env = minimal_env();
1407        let applied = add_messaging_endpoint(
1408            &mut env,
1409            add_payload("telegram", "bot-a"),
1410            MessagingEndpointId::new(),
1411            &key("k1"),
1412            fixed_now(),
1413            |_| Ok(fixed_ref()),
1414        )
1415        .unwrap();
1416        let eid = env.messaging_endpoints[applied.index].endpoint_id;
1417        // A caller-supplied new ref is validated + stamped; `provision` (here a
1418        // panicking sink) is never reached on the new-ref path.
1419        let rotated = rotate_messaging_webhook_secret(
1420            &mut env,
1421            eid,
1422            "op",
1423            &key("k2"),
1424            fixed_now(),
1425            Some("secret://local/acme/_/messaging-tg/webhook_secret"),
1426            no_provision,
1427        )
1428        .unwrap();
1429        assert!(rotated.mutated);
1430        assert_eq!(
1431            env.messaging_endpoints[rotated.index].webhook_secret_ref,
1432            Some(SecretRef::try_new("secret://local/acme/_/messaging-tg/webhook_secret").unwrap())
1433        );
1434    }
1435
1436    #[test]
1437    fn rotate_new_ref_on_non_telegram_rejected() {
1438        let mut env = minimal_env();
1439        let applied = add_messaging_endpoint(
1440            &mut env,
1441            add_payload("teams", "legal-bot"),
1442            MessagingEndpointId::new(),
1443            &key("k1"),
1444            fixed_now(),
1445            no_provision,
1446        )
1447        .unwrap();
1448        let eid = env.messaging_endpoints[applied.index].endpoint_id;
1449        let err = rotate_messaging_webhook_secret(
1450            &mut env,
1451            eid,
1452            "op",
1453            &key("k2"),
1454            fixed_now(),
1455            Some("secret://local/acme/_/messaging-x/webhook_secret"),
1456            no_provision,
1457        )
1458        .unwrap_err();
1459        assert!(matches!(err, MessagingError::InvalidSecretRef { .. }));
1460    }
1461
1462    // --- cross-op idem-key isolation (regression for the operation-scope fix) -----
1463
1464    /// Regression: add-with-K then rotate-with-K must NOT take the replay
1465    /// path — the rotate must invoke provision and report `mutated == true`.
1466    #[test]
1467    fn rotate_with_add_key_does_not_replay() {
1468        let mut env = minimal_env();
1469        let applied = add_messaging_endpoint(
1470            &mut env,
1471            add_payload("telegram", "bot-a"),
1472            MessagingEndpointId::new(),
1473            &key("k-shared"),
1474            fixed_now(),
1475            |_| Ok(fixed_ref()),
1476        )
1477        .unwrap();
1478        assert!(applied.mutated);
1479        let eid = env.messaging_endpoints[applied.index].endpoint_id;
1480        // Rotate reusing the add's key — provision MUST run.
1481        let mut provision_called = false;
1482        let rotated = rotate_messaging_webhook_secret(
1483            &mut env,
1484            eid,
1485            "op",
1486            &key("k-shared"),
1487            fixed_now(),
1488            None,
1489            |existing| {
1490                provision_called = true;
1491                assert_eq!(existing, Some(&fixed_ref()));
1492                Ok(fixed_ref())
1493            },
1494        )
1495        .unwrap();
1496        assert!(
1497            provision_called,
1498            "provision must be called for a cross-op key"
1499        );
1500        assert!(rotated.mutated);
1501        assert_eq!(env.messaging_endpoints[rotated.index].generation, 1);
1502    }
1503
1504    /// Regression: link-bundle stamps key K on an endpoint; a subsequent
1505    /// `add_messaging_endpoint` with the SAME key K and a DIFFERENT identity
1506    /// must NOT raise `IdempotencyKeyReuse` — it must create the new
1507    /// endpoint (fresh mutation, guarded by the duplicate-identity check).
1508    #[test]
1509    fn add_with_link_stamped_key_proceeds_as_fresh_mutation() {
1510        let mut env = minimal_env();
1511        let bundle_id = deployed_bundle(&mut env, "legal-pack");
1512        let idx = added(&mut env, "teams", "legal", "k1");
1513        let eid = env.messaging_endpoints[idx].endpoint_id;
1514        // Link stamps key "k-shared" onto the existing endpoint.
1515        link_messaging_bundle(
1516            &mut env,
1517            eid,
1518            bundle_id,
1519            "op",
1520            &key("k-shared"),
1521            fixed_now(),
1522        )
1523        .unwrap();
1524        // Now add a NEW endpoint with the SAME key but different identity.
1525        let applied = add_messaging_endpoint(
1526            &mut env,
1527            add_payload("slack", "ops"),
1528            MessagingEndpointId::new(),
1529            &key("k-shared"),
1530            fixed_now(),
1531            no_provision,
1532        )
1533        .unwrap();
1534        assert!(applied.mutated);
1535        assert_eq!(
1536            env.messaging_endpoints[applied.index].provider_type,
1537            "slack"
1538        );
1539    }
1540
1541    // --- telegram classifier -------------------------------------------------------
1542
1543    #[test]
1544    fn telegram_class_is_strict_on_the_dot() {
1545        assert!(is_telegram_class("telegram"));
1546        assert!(is_telegram_class("telegram.bot"));
1547        assert!(is_telegram_class("messaging.telegram"));
1548        assert!(is_telegram_class("messaging.telegram.bot"));
1549        assert!(!is_telegram_class("telegrambot"));
1550        assert!(!is_telegram_class("messaging.telegrambot"));
1551        assert!(!is_telegram_class("teams"));
1552    }
1553
1554    // --- wire-format pins ------------------------------------------------------------
1555    //
1556    // These pin the JSON encoding the PR-3b `HttpEnvironmentStore` client
1557    // established with its private DTOs. Changing them breaks the deployed
1558    // client/server pairing.
1559
1560    #[test]
1561    fn add_payload_wire_encoding() {
1562        let payload = AddMessagingEndpointPayload {
1563            provider_id: "legal-bot".to_string(),
1564            provider_type: "teams".to_string(),
1565            display_name: "Legal".to_string(),
1566            secret_refs: vec!["secret://local/default/_/p/token".to_string()],
1567            webhook_secret_ref: None,
1568            updated_by: "op".to_string(),
1569        };
1570        let json = serde_json::to_value(&payload).unwrap();
1571        // `webhook_secret_ref: None` is omitted (skip_serializing_if), so the
1572        // wire shape stays byte-compatible with pre-BYO-ref clients/servers.
1573        assert_eq!(
1574            json,
1575            serde_json::json!({
1576                "provider_id": "legal-bot",
1577                "provider_type": "teams",
1578                "display_name": "Legal",
1579                "secret_refs": ["secret://local/default/_/p/token"],
1580                "updated_by": "op",
1581            })
1582        );
1583        let back: AddMessagingEndpointPayload = serde_json::from_value(json).unwrap();
1584        assert_eq!(back, payload);
1585    }
1586
1587    #[test]
1588    fn add_payload_wire_encoding_with_webhook_ref() {
1589        let payload = AddMessagingEndpointPayload {
1590            provider_id: "tg-bot".to_string(),
1591            provider_type: "telegram".to_string(),
1592            display_name: "Bot".to_string(),
1593            secret_refs: Vec::new(),
1594            webhook_secret_ref: Some(
1595                "secret://local/default/_/messaging-byo/webhook_secret".to_string(),
1596            ),
1597            updated_by: "op".to_string(),
1598        };
1599        let json = serde_json::to_value(&payload).unwrap();
1600        assert_eq!(
1601            json["webhook_secret_ref"],
1602            "secret://local/default/_/messaging-byo/webhook_secret"
1603        );
1604        let back: AddMessagingEndpointPayload = serde_json::from_value(json).unwrap();
1605        assert_eq!(back, payload);
1606    }
1607
1608    #[test]
1609    fn rotate_payload_wire_encoding_carries_new_ref() {
1610        let payload = RotateWebhookSecretPayload {
1611            updated_by: "op".to_string(),
1612            webhook_secret_ref: Some(
1613                "secret://local/acme/_/messaging-tg/webhook_secret".to_string(),
1614            ),
1615        };
1616        let json = serde_json::to_value(&payload).unwrap();
1617        assert_eq!(
1618            json["webhook_secret_ref"],
1619            "secret://local/acme/_/messaging-tg/webhook_secret"
1620        );
1621        let back: RotateWebhookSecretPayload = serde_json::from_value(json).unwrap();
1622        assert_eq!(back, payload);
1623    }
1624
1625    #[test]
1626    fn link_payload_wire_encoding() {
1627        let payload = MessagingBundleLinkPayload {
1628            bundle_id: BundleId::new("legal-pack"),
1629            updated_by: "op".to_string(),
1630        };
1631        assert_eq!(
1632            serde_json::to_value(&payload).unwrap(),
1633            serde_json::json!({"bundle_id": "legal-pack", "updated_by": "op"})
1634        );
1635    }
1636
1637    #[test]
1638    fn welcome_flow_payload_wire_encoding() {
1639        let eid = MessagingEndpointId::new();
1640        let payload = SetMessagingWelcomeFlowPayload {
1641            endpoint_id: eid,
1642            bundle_id: BundleId::new("legal-pack"),
1643            pack_id: PackId::new("welcome-pack"),
1644            flow_id: "hello".to_string(),
1645            updated_by: "op".to_string(),
1646        };
1647        assert_eq!(
1648            serde_json::to_value(&payload).unwrap(),
1649            serde_json::json!({
1650                "endpoint_id": eid.to_string(),
1651                "bundle_id": "legal-pack",
1652                "pack_id": "welcome-pack",
1653                "flow_id": "hello",
1654                "updated_by": "op",
1655            })
1656        );
1657    }
1658
1659    #[test]
1660    fn rotate_payload_wire_encoding() {
1661        let payload = RotateWebhookSecretPayload {
1662            updated_by: "op".to_string(),
1663            webhook_secret_ref: None,
1664        };
1665        // `webhook_secret_ref: None` is omitted (skip_serializing_if), so the
1666        // body stays byte-compatible with a pre-new-ref client/server.
1667        assert_eq!(
1668            serde_json::to_value(&payload).unwrap(),
1669            serde_json::json!({"updated_by": "op"})
1670        );
1671    }
1672}