arcly-http 0.3.0

Enterprise-grade NestJS-inspired web framework on axum: zero-lock DI, declarative controllers, multi-tenant data routing, transactional outbox, ABAC, and a self-documenting OpenAPI surface
Documentation
//! Hot-rotating secrets without restarts and without locks.
//!
//! ## The problem
//!
//! Secrets read from env at boot are frozen for the process lifetime: rotating
//! `JWT_SECRET` means restarting the whole fleet and invalidating every live
//! token at once. Plaintext also lingers in the container environment
//! (`/proc/<pid>/environ`), which fails most compliance reviews.
//!
//! ## The model
//!
//! - [`SecretSource`] — *where* secrets come from (Vault, AWS Secrets Manager,
//!   env for dev). The app implements it; the framework never links a cloud SDK
//!   — the same rule that keeps `OAuth2Provider` implementations app-side.
//! - [`Rotating<T>`] — *how* live key material is held: an `ArcSwap`, so the
//!   request hot path pays one atomic pointer load (no `Mutex`/`RwLock`),
//!   while a background watcher swaps in new material atomically.
//! - [`spawn_secret_watcher`] — polls the source on an interval from
//!   `ArclyPlugin::on_start` and invokes a callback when the version changes.
//!
//! Services that own derived key material (`JwtService`, `CookieService`)
//! hold a `Rotating<…bundle…>` internally and keep the **previous** key for
//! verification during a grace window, so rotation never mass-invalidates
//! tokens that are still inside their TTL.

use std::sync::Arc;
use std::time::Duration;

use arc_swap::ArcSwap;
use futures::future::BoxFuture;
use secrecy::SecretString;

// ─── Source abstraction ───────────────────────────────────────────────────────

/// One fetched secret value plus a monotonically increasing version.
///
/// The version lets watchers detect change without comparing plaintext, and
/// guards against accidental rollback (a lower version is never applied).
pub struct SecretVersion {
    pub value: SecretString,
    pub version: u64,
}

/// External secret backend — Vault, AWS Secrets Manager, env (dev), …
///
/// Object-safe via `BoxFuture`, same pattern as `SessionStore`.
pub trait SecretSource: Send + Sync + 'static {
    fn fetch<'a>(&'a self, key: &'a str) -> BoxFuture<'a, Result<SecretVersion, String>>;
}

// ─── Lock-free rotating holder ────────────────────────────────────────────────

/// Atomically swappable key material.
///
/// Hot path: [`load`](Self::load) is a single atomic pointer load (~1 ns) —
/// no locks, no allocation. Control path: [`store`](Self::store) replaces the
/// whole bundle at once, so readers always observe a consistent key set.
pub struct Rotating<T> {
    inner: ArcSwap<T>,
}

impl<T> Rotating<T> {
    pub fn new(initial: T) -> Self {
        Self {
            inner: ArcSwap::from_pointee(initial),
        }
    }

    #[inline]
    pub fn load(&self) -> arc_swap::Guard<Arc<T>> {
        self.inner.load()
    }

    /// Swap in a new bundle. Call from rotation/control paths only.
    pub fn store(&self, next: T) {
        self.inner.store(Arc::new(next));
    }
}

// ─── Background watcher ───────────────────────────────────────────────────────

/// Poll `source` for `key` every `interval`; when the version increases,
/// invoke `on_change` with the new secret.
///
/// Spawn from `ArclyPlugin::on_start` — the existing lifecycle hook — so no
/// new mechanism is needed for graceful shutdown (the task dies with the
/// runtime). Fetch errors are logged and retried on the next tick; the
/// previous key material stays live, so a flaky source can never lock the
/// service out of its own keys.
pub fn spawn_secret_watcher(
    source: Arc<dyn SecretSource>,
    key: &'static str,
    interval: Duration,
    on_change: impl Fn(SecretVersion) + Send + Sync + 'static,
) {
    tokio::spawn(async move {
        let mut last_version: u64 = 0;
        let mut ticker = tokio::time::interval(interval);
        // First tick fires immediately; skip it — boot already loaded v1.
        ticker.tick().await;
        loop {
            ticker.tick().await;
            match source.fetch(key).await {
                Ok(sv) if sv.version > last_version => {
                    tracing::info!(key, version = sv.version, "secret rotated — applying");
                    last_version = sv.version;
                    on_change(sv);
                }
                Ok(_) => { /* unchanged */ }
                Err(e) => {
                    tracing::warn!(key, error = %e, "secret fetch failed — keeping current key");
                }
            }
        }
    });
}