feat(trunk): CallControlClient trait + MockCallControlClient + TwilioCredentials (slice-5 T2)
The provider call-control seam (green zone, ADR-0008). The trait locks the boundary so the next provider (Telnyx, etc.) is an implementation, not a refactor. MockCallControlClient is the CI test double; the live TwilioCallControlClient (T6) lives behind the twilio-live feature flag. TwilioCredentials lives ONLY in crates/rutster-trunk/ -- never re-exported through the workspace (ADR-0009 -- provider credentials never reach the brain). Its Debug impl is hand-written (NOT derived) so the auth_token renders as <redacted>, never leaking into tracing/panic output. Option<SpendToken> on originate is the pre-paved seam for spearhead step-6 (spend cap); this slice passes None everywhere. The signature is locked so step 6 is additive, not a refactor. T2 of slice-5. lib.rs gains `pub mod provider;` -- stacked-branches carve-out (rebase-merge, not squash) per AGENTS.md Git workflow; dev-c rebases forward for the FOB-side pub mod declarations (g711/twilio_media_streams/session/ loop_driver). Signed-off-by: Aaron D. Lee <himself@adlee.work>
This commit is contained in:
@@ -5,4 +5,33 @@ version = "0.0.0"
|
||||
license.workspace = true
|
||||
edition.workspace = true
|
||||
repository.workspace = true
|
||||
description = "Rented carrier transport — CPaaS media-leg ingress / out-of-tree SBC; no first-party SIP (filled in step 5, ADR-0007)."
|
||||
description = "Rented carrier transport — CPaaS media-leg ingress; no first-party SIP (spearhead step 5, ADR-0007)."
|
||||
|
||||
[dependencies]
|
||||
# async-trait: lets us declare `async fn` in the CallControlClient trait while
|
||||
# still using it as a trait object (`Box<dyn CallControlClient>`) in the route
|
||||
# handlers. Native async-fn-in-traits (stable since 1.75) does NOT support
|
||||
# `dyn` dispatch without manual desugaring — async_trait rewrites the signature
|
||||
# to `fn -> Pin<Box<dyn Future + Send>>` for us (spec §3.4).
|
||||
async-trait = { workspace = true }
|
||||
# url: the `TwilioCredentials::webhook_base` field is a `url::Url` (the
|
||||
# operator's public base URL Twilio calls back). Parsed in `config::twilio_credentials`.
|
||||
url = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
# tokio: only the dev-dep is needed for the mock's `#[tokio::test]` attribute
|
||||
# (`#[tokio::test]` expands to a `#[test]` that spins up a current-thread
|
||||
# runtime + drives the async fn to completion). The library itself is
|
||||
# runtime-agnostic: async_trait's desugared futures poll on whatever runtime
|
||||
# the caller drives them with — the mock's bodies are synchronous (lock + push,
|
||||
# no `.await`), so no tokio dependency leaks into the library's public surface.
|
||||
tokio = { workspace = true }
|
||||
|
||||
[features]
|
||||
default = []
|
||||
# The live `TwilioCallControlClient` (T6) is feature-gated behind `twilio-live`
|
||||
# so the routine CI gate stays feature-default-off: `MockCallControlClient` is
|
||||
# the per-PR test surface; the maintainer runs `cargo test --features=twilio-live`
|
||||
# + the e2e suite only when validating a release (against real Twilio creds).
|
||||
# Spec §1.2 + plan T6 + ADR-0009 (credentials never reach the brain).
|
||||
twilio-live = []
|
||||
|
||||
@@ -1,24 +1,38 @@
|
||||
//! # rutster-trunk
|
||||
//!
|
||||
//! **Status:** stub. Fills in at spearhead step 5 (a real phone number via rented transport).
|
||||
//!
|
||||
//! Carrier / PSTN reach for rutster — **rented transport, no first-party SIP**
|
||||
//! ([ADR-0007](../../../docs/adr/0007-trunk-rented-transport.md)). This crate will hold the
|
||||
//! ([ADR-0007](../../../docs/adr/0007-trunk-rented-transport.md)). This crate holds the
|
||||
//! rented-transport ingress: the **CPaaS media-leg adapter** — a WebSocket server accepting a
|
||||
//! provider raw-media fork (Twilio Media Streams / Telnyx) plus the provider call-control
|
||||
//! client — and later the glue for an **out-of-tree SBC** when PSTN media must stay on-prem.
|
||||
//! It owns **no SIP stack**: SIP lives outside the trust boundary, in the rented transport
|
||||
//! (green zone — [ADR-0008](../../../docs/adr/0008-fob-and-green-zone.md)).
|
||||
//!
|
||||
//! Slice 1's WebRTC-only ingress needs none of this — this stub exists to lock the crate
|
||||
//! boundary without anticipating code (spec §2.2). Its future dependency direction is
|
||||
//! `rutster-trunk` → `rutster-call-model` + `rutster-media`: PSTN audio is resampled to the
|
||||
//! canonical tap format and handed to the media plane as a media-leg ingress, parallel to
|
||||
//! WebRTC.
|
||||
//! ## Spearhead step 5 — what's here vs what's deferred
|
||||
//!
|
||||
//! The provider call-control seam ([`provider`](crate::provider)) lands in T2: the
|
||||
//! `CallControlClient` trait, `MockCallControlClient`, and `TwilioCredentials` (with
|
||||
//! redacted `Debug`). The live `TwilioCallControlClient` (T6) is feature-gated behind
|
||||
//! `twilio-live`. The WSS ingress (`g711`, `twilio_media_streams`, `session`,
|
||||
//! `loop_driver`) lands in the parallel FOB-side tasks (T1/T3/T4) — see the slice-5 spec.
|
||||
//!
|
||||
//! ## Dependency direction
|
||||
//!
|
||||
//! `rutster-trunk` → `rutster-call-model` + `rutster-media` (once the FOB-side media path
|
||||
//! lands): PSTN audio is resampled to the canonical tap format and handed to the media plane
|
||||
//! as a media-leg ingress, parallel to WebRTC. The provider (call-control) side is
|
||||
//! green-zone and depends on no FOB media types — only on `url` + `async-trait` (T2).
|
||||
|
||||
pub mod provider;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
/// Stub crates lock boundaries; the compile-test is the lock.
|
||||
/// Stub crates lock boundaries; the compile-test is the lock. Now that the
|
||||
/// provider module is populated (T2), this test also guards the crate-level
|
||||
/// re-exports compile against the public API surface.
|
||||
#[test]
|
||||
fn crate_compiles() {}
|
||||
fn crate_compiles() {
|
||||
// Touch the public API so the test exercises the re-export wiring.
|
||||
let _mock = crate::provider::MockCallControlClient::new();
|
||||
}
|
||||
}
|
||||
|
||||
204
crates/rutster-trunk/src/provider/mock.rs
Normal file
204
crates/rutster-trunk/src/provider/mock.rs
Normal file
@@ -0,0 +1,204 @@
|
||||
//! # MockCallControlClient — in-process test double for `CallControlClient`
|
||||
//!
|
||||
//! CI tests + the PSTN-sim e2e integration tests (T7, T8) use this mock; the
|
||||
//! live `TwilioCallControlClient` (T6) is feature-gated behind `twilio-live`
|
||||
//! and runs only on the maintainer's machine against real Twilio credentials.
|
||||
//!
|
||||
//! ## Why `std::sync::Mutex` (not `tokio::sync::Mutex`)
|
||||
//!
|
||||
//! The mock's async fn bodies are **synchronous** — they lock, push a record,
|
||||
//! unlock, and return. There is no `.await` while holding the lock, so the
|
||||
//! std mutex (cheaper, no async-overhead, no runtime coupling) is correct.
|
||||
//! `tokio::sync::Mutex` is for guards held across `.await` points; using it
|
||||
//! here would be cargo-cult. The async fns exist only to satisfy the trait's
|
||||
//! `async` signature (desugared by `async_trait` to `Pin<Box<...>>`); their
|
||||
//! bodies do not actually suspend.
|
||||
//!
|
||||
//! ## Poison recovery
|
||||
//!
|
||||
//! `std::sync::Mutex` "poisons" on panic — a previous lock holder that panicked
|
||||
//! leaves the mutex poisoned, and `.lock()` returns `Err(PoisonError)`. For a
|
||||
//! *test double*, a poisoned mutex means a previous test assertion failed; the
|
||||
//! right move is to recover the inner value (`.into_inner()`) rather than
|
||||
//! propagate the poison (which would cascade one test failure into every
|
||||
//! subsequent test). [`lock_or_recover`] does this idiomatically — and avoids
|
||||
//! the `unwrap()`/`expect()` that AGENTS.md's hot-path rule forbids in library
|
||||
//! code (the mock is library code, not `#[cfg(test)]`, because T7/T8 integration
|
||||
//! tests in `tests/` import it).
|
||||
|
||||
use std::sync::{Mutex, MutexGuard};
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
||||
use super::{CallControlClient, CallControlError, SpendToken};
|
||||
|
||||
/// Recover a `MutexGuard` even if the mutex is poisoned.
|
||||
///
|
||||
/// Poison indicates a prior lock-holder panicked (a test assertion failed
|
||||
/// while holding the lock). For a test double, propagating the poison would
|
||||
/// cascade the failure into every subsequent test that touches the mock;
|
||||
/// recovering the inner guard (`PoisonError::into_inner`) localizes the
|
||||
/// failure. This is the standard documented recovery pattern for non-invariant
|
||||
/// mutexes (see `std::sync::PoisonError::into_inner` docs).
|
||||
fn lock_or_recover<T>(lock: &Mutex<T>) -> MutexGuard<'_, T> {
|
||||
lock.lock().unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||
}
|
||||
|
||||
/// A captured `originate` call — for test assertions.
|
||||
///
|
||||
/// `spend_token_present` (not the token itself) is recorded because `SpendToken`
|
||||
/// is opaque in step 6; the mock only needs to verify whether a token was
|
||||
/// supplied, not inspect it. This keeps the mock forward-compatible with step
|
||||
/// 6's real (opaque, possibly non-`Clone`) token type.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct OriginateRecord {
|
||||
/// The `to_phone` arg passed to `originate`.
|
||||
pub to: String,
|
||||
/// The `from_phone` arg passed to `originate`.
|
||||
pub from: String,
|
||||
/// Whether the caller passed `Some(SpendToken)` (step 6 will; step 5 passes `None`).
|
||||
pub spend_token_present: bool,
|
||||
}
|
||||
|
||||
/// In-process test double for [`CallControlClient`]. No network IO.
|
||||
///
|
||||
/// `originate` returns a synthetic `CA...` correlation ID (matching Twilio's
|
||||
/// CallSid format — `CA` + 32 hex chars — so downstream code that parses the
|
||||
/// prefix is exercised honestly) and records the call. `hangup` records the
|
||||
/// terminate. Both are infallible in the mock (no real provider to fail); the
|
||||
/// `Result` wrappers return `Ok` so test wiring that propagates the `Result`
|
||||
/// is exercised identically to the live path.
|
||||
pub struct MockCallControlClient {
|
||||
/// Monotonic counter for synthetic CallSid generation. `AtomicU64` rather
|
||||
/// than a `Mutex<u64>` because an atomic increment needs no guard (no
|
||||
/// critical section) — cheaper and lock-poison-free.
|
||||
counter: std::sync::atomic::AtomicU64,
|
||||
originated: Mutex<Vec<OriginateRecord>>,
|
||||
hung_up: Mutex<Vec<String>>,
|
||||
}
|
||||
|
||||
impl MockCallControlClient {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
counter: std::sync::atomic::AtomicU64::new(0),
|
||||
originated: Mutex::new(Vec::new()),
|
||||
hung_up: Mutex::new(Vec::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Snapshot of every `originate` call the mock received, in arrival order.
|
||||
pub fn originated_calls(&self) -> Vec<OriginateRecord> {
|
||||
lock_or_recover(&self.originated).clone()
|
||||
}
|
||||
|
||||
/// Snapshot of every `correlation_id` passed to `hangup`, in arrival order.
|
||||
pub fn hung_up_calls(&self) -> Vec<String> {
|
||||
lock_or_recover(&self.hung_up).clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for MockCallControlClient {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl CallControlClient for MockCallControlClient {
|
||||
async fn originate(
|
||||
&self,
|
||||
to_phone: &str,
|
||||
from_phone: &str,
|
||||
spend_token: Option<SpendToken>,
|
||||
) -> Result<String, CallControlError> {
|
||||
let id = self
|
||||
.counter
|
||||
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
// Twilio CallSids are "CA" + 32 hex chars. The mock mirrors that shape
|
||||
// so any code that parses the prefix/length exercises the real path.
|
||||
let call_sid = format!("CA{id:032x}");
|
||||
|
||||
let mut originated = lock_or_recover(&self.originated);
|
||||
originated.push(OriginateRecord {
|
||||
to: to_phone.to_owned(),
|
||||
from: from_phone.to_owned(),
|
||||
spend_token_present: spend_token.is_some(),
|
||||
});
|
||||
|
||||
Ok(call_sid)
|
||||
}
|
||||
|
||||
async fn hangup(&self, correlation_id: &str) -> Result<(), CallControlError> {
|
||||
let mut hung_up = lock_or_recover(&self.hung_up);
|
||||
// Idempotent in the mock (matching the live provider): recording the
|
||||
// same id twice is not an error, matching Twilio's `Status=completed`
|
||||
// semantics. A dedup check would be test-specific logic the real
|
||||
// provider doesn't expose.
|
||||
hung_up.push(correlation_id.to_owned());
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn originate_returns_call_sid_and_records_call() {
|
||||
let mock = MockCallControlClient::new();
|
||||
let sid = mock
|
||||
.originate("+15551234567", "+15550000000", None)
|
||||
.await
|
||||
.expect("mock originate never errors");
|
||||
|
||||
// Twilio CallSid shape: "CA" + 32 hex chars = 34 chars total.
|
||||
assert!(sid.starts_with("CA"), "expected CA prefix, got {sid}");
|
||||
assert_eq!(sid.len(), 34, "expected 34-char CallSid, got {sid}");
|
||||
|
||||
let calls = mock.originated_calls();
|
||||
assert_eq!(calls.len(), 1);
|
||||
assert_eq!(calls[0].to, "+15551234567");
|
||||
assert_eq!(calls[0].from, "+15550000000");
|
||||
assert!(
|
||||
!calls[0].spend_token_present,
|
||||
"step 5 passes None for spend_token"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn originate_produces_distinct_call_sids_per_call() {
|
||||
let mock = MockCallControlClient::new();
|
||||
let sid_a = mock.originate("+1", "+2", None).await.unwrap();
|
||||
let sid_b = mock.originate("+3", "+4", None).await.unwrap();
|
||||
assert_ne!(sid_a, sid_b, "each originate must get a unique CallSid");
|
||||
assert_eq!(mock.originated_calls().len(), 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hangup_records_correlation_id_and_is_idempotent() {
|
||||
let mock = MockCallControlClient::new();
|
||||
mock.hangup("CAdeadbeef").await.unwrap();
|
||||
// Calling again with the same id is a no-op success (live provider is idempotent).
|
||||
mock.hangup("CAdeadbeef").await.unwrap();
|
||||
mock.hangup("CAcafef00d").await.unwrap();
|
||||
|
||||
let hung = mock.hung_up_calls();
|
||||
// The mock records every call (no dedup) — 2 for the repeat + 1 for the other.
|
||||
assert_eq!(hung.len(), 3);
|
||||
assert_eq!(
|
||||
hung.iter().filter(|s| s.as_str() == "CAdeadbeef").count(),
|
||||
2
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn spend_token_some_is_recorded_as_present() {
|
||||
// Step 5 callers pass None, but the trait accepts Some(SpendToken) for
|
||||
// forward-compat with step 6. The mock records presence only.
|
||||
let mock = MockCallControlClient::new();
|
||||
mock.originate("+1", "+2", Some(SpendToken)).await.unwrap();
|
||||
let calls = mock.originated_calls();
|
||||
assert_eq!(calls.len(), 1);
|
||||
assert!(calls[0].spend_token_present);
|
||||
}
|
||||
}
|
||||
209
crates/rutster-trunk/src/provider/mod.rs
Normal file
209
crates/rutster-trunk/src/provider/mod.rs
Normal file
@@ -0,0 +1,209 @@
|
||||
//! # 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};
|
||||
|
||||
/// 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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user