//! # Provider — the call-control seam (green zone, ADR-0008) //! //! This module locks the boundary between rutster's FOB (the memory-safe Rust //! trust domain that owns the per-call media vertical) and the rented carrier //! transport (a CPaaS provider — Twilio for the spearhead MVP, Telnyx or others //! later). Per [ADR-0007](../../../../docs/adr/0007-trunk-rented-transport.md), //! rutster parses **zero SIP bytes**: PSTN reach is *rented transport*. //! Per [ADR-0008](../../../../docs/adr/0008-fob-and-green-zone.md), the call-control //! client lives in the **green zone** — arm's length, behind a trait — because it //! is neither hot-path (it fires only on originate + inbound-webhook receipt) nor //! security-constitutive (the actual media never transits it; only REST commands do). //! //! ## Why a trait (not a concrete Twilio client inline) //! //! The trait locks the seam so the **next provider** (Telnyx, etc.) is an //! *implementation*, not a *refactor*. The binary's route handlers depend on //! `dyn CallControlClient`, not on `TwilioCallControlClient` directly — swapping //! providers is a constructor change in `main.rs`, not a rewrite of the handlers. //! This is the same "lock the seam" discipline that `AudioPipe` applies to the //! media direction. //! //! ## Credential isolation (ADR-0009 — load-bearing for spearhead step 6) //! //! [`TwilioCredentials`] lives **only** in this crate (`rutster-trunk`). It is //! `pub` to the binary crate (`crates/rutster/`) so `config.rs` can construct //! it from env vars and hand it to `TwilioCallControlClient::new`, but it is //! **never re-exported through the workspace root**, never appears in //! `rutster-media`'s or `rutster-tap`'s public API, and never crosses the //! WebSocket tap protocol to the brain. The brain carries ONLY audio (PCM //! in/out), function-call events, and `speech_started`/`speech_stopped` //! advisories — never the account SID, the `auth_token`, the REST endpoint //! URL, or the `CallSid`. T10's static assertion test re-checks this //! invariant mechanically. //! //! The `auth_token` field's [`Debug`](std::fmt::Debug) impl is **hand-written** //! (NOT derived) so the token is rendered as `` — derived `Debug` //! would print the secret in any `tracing::debug!` or `{:?}` log line. This is //! the same posture as `api_key.rs`'s redacted Debug (slice-3). use std::net::SocketAddr; pub mod mock; pub use mock::{MockCallControlClient, OriginateRecord}; // The live `TwilioCallControlClient` (T6) is feature-gated behind `twilio-live`. // Including it unconditionally would pull `reqwest` into the default CI build's // dep graph (against the lean-default-features-off posture); the `#[cfg]` here // keeps the routine gate clean. `#[cfg(feature = ...)]` on a `pub mod` declaration // means the module is only compiled when the feature is enabled — `lib.rs`'s // `pub mod provider;` is unconditional (the trait + mock + credentials are always // available); only the live REST client is gated. #[cfg(feature = "twilio-live")] pub mod twilio; #[cfg(feature = "twilio-live")] pub use twilio::TwilioCallControlClient; /// Call-control operations a rented-transport provider exposes. /// /// `Send + Sync` so it can live behind an `Arc` shared /// across the axum route handlers' tokio tasks. The `#[async_trait]` attribute /// (from the `async-trait` crate) rewrites each `async fn` into a synchronous /// `fn -> Pin + Send>>` — that boxing is *why* the /// trait is usable as a trait object (`dyn CallControlClient`). Native /// `async fn` in traits (stable since Rust 1.75) does not yet support `dyn` /// dispatch, so the macro is the pragmatic bridge (spec §3.4). #[async_trait::async_trait] pub trait CallControlClient: Send + Sync { /// Originate an outbound call to `to_phone` from `from_phone`. /// /// The provider answers the PSTN call itself, opens a Media Streams fork /// back to rutster, and returns a **provider-assigned correlation ID** /// (Twilio: the `CallSid`, a `CA...` string). The binary correlates this /// ID with its own `ChannelId` for log/CDR join. /// /// `spend_token` is the **pre-paved seam for spearhead step 6** (the spend /// cap). This slice (step 5) passes `None` everywhere — no spend gate yet. /// Step 6's spend gate will mint a token before any originate is dispatched /// and pass `Some(token)` here; the live impl will reject `None` once the /// gate is live. **Do not remove this parameter** "to simplify" — step 6 /// needs the signature stable so it is additive, not a refactor. async fn originate( &self, to_phone: &str, from_phone: &str, spend_token: Option, ) -> Result; /// Hang up an in-progress call. Idempotent — calling with an already-ended /// `correlation_id` returns `Ok(())` (the provider's REST API is itself /// idempotent on `Status=completed`). Returns after the provider /// acknowledges the terminate. async fn hangup(&self, correlation_id: &str) -> Result<(), CallControlError>; } /// A provider call-control failure. A newtype over `String` rather than a /// rich enum because the MVP's only concern is "did it work" (the route handler /// maps `Err` → HTTP 502); structured error variants (auth-failed vs /// rate-limited vs network) land with the second provider (Telnyx) when the /// distinction starts to matter operationally. #[derive(Debug, Clone)] pub struct CallControlError(pub String); impl std::fmt::Display for CallControlError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "call-control error: {}", self.0) } } impl std::error::Error for CallControlError {} /// A placeholder for the spearhead step-6 **spend-gate token**. /// /// This slice (step 5) passes `None` for `spend_token` on every `originate` /// call — the spend/abuse gate ([ADR-0009](../../../../docs/adr/0009-spend-gate-honest-rescope.md)) /// does not exist yet. The type exists so the trait signature is **locked** /// now: step 6 will flesh `SpendToken` out into a real carrier (minted by the /// spend gate, carrying the per-call budget) without changing /// `CallControlClient::originate`'s signature — every existing call site stays /// valid; only the `None` → `Some(token)` migration is incremental. /// /// A unit struct (not a zero-field tuple struct) purely for construction /// ergonomics: `SpendToken` (no parens) is how the spend gate will build it. pub struct SpendToken; /// Twilio account credentials + the operator's media-ingress topology. /// /// Lives **only** in `rutster-trunk` (ADR-0009 — provider credentials never /// reach the brain). `pub` to the binary crate so `config::twilio_credentials` /// can construct it; never re-exported through the workspace. /// /// `Debug` is **hand-implemented** below to redact `auth_token` — deriving /// `Debug` would print the secret to any `tracing::debug!` / `{:?}` / panic /// backtrace line. The redaction is a security-constitutive choice, not a /// convenience. /// /// The `twilio-live` feature gates the live client (`TwilioCallControlClient`, /// T6) that consumes this struct. The struct itself is unconditionally /// compiled because `config.rs`'s env parser returns `Option` /// regardless of feature flags — the binary holds the parsed config even when /// the live REST client is compiled out of the CI default build. #[derive(Clone)] pub struct TwilioCredentials { /// Twilio account SID (the `AC...` identifier). Not a secret per se (it /// appears in URLs), but treated as sensitive for defense-in-depth. pub account_sid: String, /// Twilio auth token — **NEVER logged** (see the hand-impl `Debug` below), /// never passed to the brain, never placed in a tracing field. The live /// client uses it for HTTP basic auth on the REST API only. pub auth_token: String, /// The local socket address where rutster's `TwilioMediaStreamsServer` /// (T3) binds to accept inbound Twilio Media Streams WSS connections. /// Twilio is told to fork audio here (via the TwiML `` directive). pub media_streams_bind: SocketAddr, /// The operator's public base URL — the address Twilio calls back via the /// webhook (`POST /v1/trunk/webhook`) for inbound-call signaling. In local /// dev this is `http://localhost:8080`; production is a public HTTPS URL. pub webhook_base: url::Url, } impl std::fmt::Debug for TwilioCredentials { /// Hand-implemented so `auth_token` renders as ``. /// /// Do NOT replace with `#[derive(Debug)]` — that would print the secret /// token in any `{:?}` formatting (panic backtraces, `tracing::debug!`, /// test-failure diffs). The other fields are operator-configured and /// safe to log. This mirrors `api_key.rs`'s redacted-Debug pattern /// (slice-3) — the codebase's standing posture on secrets. fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("TwilioCredentials") .field("account_sid", &self.account_sid) .field("auth_token", &"") .field("media_streams_bind", &self.media_streams_bind) .field("webhook_base", &self.webhook_base) .finish() } } #[cfg(test)] mod tests { use super::*; #[test] fn twilio_credentials_debug_redacts_auth_token() { let creds = TwilioCredentials { account_sid: "AC_test_sid".into(), auth_token: "super-secret-token-do-not-log".into(), media_streams_bind: "0.0.0.0:8081".parse().unwrap(), webhook_base: "https://example.com".parse().unwrap(), }; let rendered = format!("{:?}", creds); assert!( !rendered.contains("super-secret-token-do-not-log"), "auth_token leaked into Debug output: {rendered}" ); assert!( rendered.contains(""), "Debug output should mark auth_token as : {rendered}" ); // Non-secret fields remain visible for operator log-correlation. assert!(rendered.contains("AC_test_sid")); assert!(rendered.contains("0.0.0.0:8081")); } #[test] fn call_control_error_displays_and_contains_message() { let err = CallControlError("boom".into()); assert_eq!(format!("{err}"), "call-control error: boom"); // std::error::Error bounds check (compiles only if the impl is present). let _: &dyn std::error::Error = &err; } #[test] fn spend_token_is_unit_constructable_for_future_step6_use() { // This slice passes None; the construction here proves the type is // usable as Some(SpendToken) once step 6 mints real tokens. let _token = SpendToken; let none: Option = None; assert!(none.is_none()); } /// ADR-0009 static assertion (spec §7 done-criteria #10): `TwilioCredentials` /// lives ONLY in the `rutster-trunk` crate. This test pins the type's /// locality — it compiles only because `crate::provider::TwilioCredentials` /// resolves (the type's canonical home). Sibling crates (`rutster-media`, /// `rutster-tap`) do NOT depend on `rutster-trunk`, so they cannot re-export /// the type — the invariant is structural (the Cargo dep graph). If someone /// later adds a `pub use` re-export through the workspace root or a sibling, /// this test's doc + the dep-graph change would both need revisiting /// (a reviewable Cargo.toml edit). The binary's `config::twilio_credentials` /// is the single expected import path (`rutster_trunk::provider::TwilioCredentials`). #[test] fn twilio_credentials_is_trunk_local_adr0009() { // Compile-time path resolution: if TwilioCredentials moved out of this // crate, this line fails to compile (the test breaks the build). fn _accepts_trunk_local_credentials(_: crate::provider::TwilioCredentials) {} // Touch the fn so it's not dead-code-eliminated before the type check. let _ = _accepts_trunk_local_credentials; } }