Spec §7 done-criteria #10 demands a static assertion that TwilioCredentials lives ONLY in rutster-trunk (ADR-0009 -- provider credentials never reach the brain). The test compiles only because crate::provider::TwilioCredentials resolves (the type's canonical home); if someone moved/re-exported it through the workspace root or a sibling crate, this test's doc + the dep-graph change would surface in review. The invariant is structural: sibling crates (rutster-media, rutster-tap) do not depend on rutster-trunk, so the type cannot reach them. The binary's config::twilio_credentials is the single expected import path. Signed-off-by: Aaron D. Lee <himself@adlee.work>
241 lines
12 KiB
Rust
241 lines
12 KiB
Rust
//! # 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 `<redacted>` — 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<dyn CallControlClient>` 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<Box<dyn Future<Output = ...> + 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<SpendToken>,
|
|
) -> Result<String, CallControlError>;
|
|
|
|
/// 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<TwilioCredentials>`
|
|
/// 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 `<Connect>` 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 `<redacted>`.
|
|
///
|
|
/// 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", &"<redacted>")
|
|
.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("<redacted>"),
|
|
"Debug output should mark auth_token as <redacted>: {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<SpendToken> = 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;
|
|
}
|
|
}
|