Skip to main content

chio_api_protect/proxy/
state.rs

1use super::*;
2
3use chio_http_serve::{
4    apply_server_hygiene, run_until_drained, ServeError, ServeHygieneConfig, ShutdownController,
5};
6use std::fs;
7use std::path::PathBuf;
8use std::time::Duration;
9
10/// Interval between reserved-hold reaper sweeps. A hold reserved on
11/// `/v1/evaluate` but never reconciled is released once its execution-nonce TTL
12/// lapses; sweeping on this cadence bounds how long abandoned budget stays held.
13const RESERVED_HOLD_REAP_INTERVAL_SECS: u64 = 30;
14
15/// Spawn the reserved-hold reaper and retain its `JoinHandle` on the shared
16/// state so the task can be aborted when the server stops. Dropping a
17/// `JoinHandle` only detaches the task (it keeps running); retaining it is what
18/// binds the reaper's lifetime to the server's. A no-op without a mediation
19/// kernel, since nothing reserves holds there.
20pub(crate) async fn spawn_reserved_hold_reaper(state: &Arc<ProxyState>) {
21    if state.mediation_kernel.is_none() {
22        return;
23    }
24    let reaper_state = Arc::clone(state);
25    let handle = tokio::spawn(async move {
26        let mut ticker = tokio::time::interval(std::time::Duration::from_secs(
27            RESERVED_HOLD_REAP_INTERVAL_SECS,
28        ));
29        ticker.tick().await;
30        loop {
31            ticker.tick().await;
32            let now = chrono::Utc::now().timestamp();
33            match reap_expired_reserved_holds_once(&reaper_state, now).await {
34                Ok(0) => {}
35                Ok(released) => {
36                    info!(released, "reaped expired reserved budget holds");
37                }
38                Err(error) => {
39                    warn!("reserved-hold reaper failed: {error}");
40                }
41            }
42        }
43    });
44    *state.reaper_handle.lock().await = Some(handle);
45}
46
47/// Extra window the drain holds open beyond the upstream hop ceiling so a hop
48/// that trips its own deadline still has time to record its receipt before the
49/// forced drain closes the connection.
50const PROXY_DRAIN_MARGIN: Duration = Duration::from_secs(5);
51
52fn authority_sibling_paths(receipt_path: &str) -> (PathBuf, PathBuf) {
53    let base = chio_store_sqlite::sqlite_filesystem_path(receipt_path);
54    let mut lock_root = base.as_os_str().to_os_string();
55    lock_root.push(".authority-locks");
56    let lock_root = PathBuf::from(lock_root);
57    (lock_root.join("authority.db"), lock_root)
58}
59
60fn prepare_authority_lock_root(path: &std::path::Path) -> Result<(), ProtectError> {
61    fs::create_dir_all(path).map_err(|error| ProtectError::Config(error.to_string()))?;
62    #[cfg(unix)]
63    {
64        use std::os::unix::fs::PermissionsExt;
65        fs::set_permissions(path, fs::Permissions::from_mode(0o700))
66            .map_err(|error| ProtectError::Config(error.to_string()))?;
67    }
68    Ok(())
69}
70
71/// Drain window for the proxy serve site, derived from the configured upstream
72/// hop ceiling.
73///
74/// The proxy records its receipt inside the request handler, after the upstream
75/// call returns (success or failure), and runs with no generic request timeout:
76/// that outer layer would drop the handler mid-hop and skip the receipt entirely.
77/// Bounding the upstream call is what keeps a stalled upstream from becoming an
78/// unbounded handler, and holding the drain a margin above that ceiling is what
79/// lets an in-flight hop resolve and record its receipt before a shutdown
80/// force-closes the connection. Deriving the drain from the (configurable) hop
81/// ceiling preserves that ordering for any configured value, not just the default.
82fn proxy_drain_timeout(upstream_request_timeout: Duration) -> Duration {
83    upstream_request_timeout.saturating_add(PROXY_DRAIN_MARGIN)
84}
85
86/// Derive the revocation store path that sits beside a receipt store path.
87///
88/// The revocation store lives in a sibling database so a revoked capability
89/// survives a restart. When the receipt path is a SQLite URI carrying query
90/// parameters (for example `file:/var/lib/chio/receipts.db?mode=rwc`), the
91/// `.revocations` suffix must land on the database filename, not inside the
92/// query string, or the revocation store opens the wrong URI. Split any URI
93/// query off first and re-attach it after the suffix, matching how the receipt
94/// store itself interprets the path, so a plain filesystem path and a URI both
95/// resolve to a distinct sibling database.
96fn revocation_sibling_path(receipt_path: &str) -> String {
97    match receipt_path.split_once('?') {
98        Some((base, query)) => format!("{base}.revocations?{query}"),
99        None => format!("{receipt_path}.revocations"),
100    }
101}
102
103/// Stored receipts for inspection and querying.
104pub(crate) struct ReceiptLog {
105    pub(crate) receipts: Vec<HttpReceipt>,
106}
107
108/// Stored Chio receipts for tool-call sidecar aliases.
109pub(crate) struct ToolReceiptLog {
110    pub(crate) receipts: Vec<ChioReceipt>,
111}
112
113/// Reserved primary key the readiness probe writes and immediately rolls back,
114/// so exercising the receipt write path never leaves a durable row.
115const RECEIPT_READINESS_PROBE_ID: &str = "__chio_readiness_probe__";
116
117pub(crate) struct SqliteReceiptStore {
118    connection: Connection,
119}
120
121impl SqliteReceiptStore {
122    pub(crate) fn open(path: &str) -> Result<Self, ProtectError> {
123        let connection = Connection::open(path)
124            .map_err(|error| ProtectError::ReceiptStore(error.to_string()))?;
125        // `chio api protect` co-locates the approval store, the kernel receipt
126        // store, and this HTTP receipt table in one SQLite file. The kernel
127        // receipt store runs that file in WAL mode with a busy timeout; a writer
128        // on the same file without a busy timeout turns a lock another writer
129        // holds for a moment into an immediate SQLITE_BUSY error, so this
130        // connection matches the same durability and timeout pragmas.
131        connection
132            .execute_batch(
133                "
134                PRAGMA journal_mode = WAL;
135                PRAGMA synchronous = FULL;
136                PRAGMA busy_timeout = 5000;
137                PRAGMA foreign_keys = ON;
138                ",
139            )
140            .map_err(|error| ProtectError::ReceiptStore(error.to_string()))?;
141        connection
142            .execute_batch(
143                "
144                CREATE TABLE IF NOT EXISTS http_receipts (
145                    id TEXT PRIMARY KEY,
146                    receipt_json TEXT NOT NULL
147                );
148                CREATE TABLE IF NOT EXISTS tool_receipts (
149                    id TEXT PRIMARY KEY,
150                    receipt_json TEXT NOT NULL
151                );
152                CREATE TABLE IF NOT EXISTS revoked_capabilities (
153                    capability_id TEXT PRIMARY KEY
154                );
155                ",
156            )
157            .map_err(|error| ProtectError::ReceiptStore(error.to_string()))?;
158        Ok(Self { connection })
159    }
160
161    /// Reachability check of the receipt write path, for the readiness probe.
162    /// A bare `SELECT 1` answers even when the receipt tables have been dropped or
163    /// the database has gone read-only or full, so it would keep an instance in
164    /// rotation while every append fails after an already-allowed upstream call.
165    /// This exercises the real receipt tables and the write path inside a
166    /// transaction that is always rolled back: a dropped table, a read-only mount,
167    /// or a full disk fails readiness, and no probe row is ever persisted.
168    pub(crate) fn is_reachable(&self) -> bool {
169        self.probe_receipt_write_path().is_ok()
170    }
171
172    fn probe_receipt_write_path(&self) -> Result<(), rusqlite::Error> {
173        let tx = self.connection.unchecked_transaction()?;
174        tx.execute(
175            "INSERT OR REPLACE INTO http_receipts (id, receipt_json) VALUES (?1, ?2)",
176            params![RECEIPT_READINESS_PROBE_ID, "{}"],
177        )?;
178        tx.execute(
179            "INSERT OR REPLACE INTO tool_receipts (id, receipt_json) VALUES (?1, ?2)",
180            params![RECEIPT_READINESS_PROBE_ID, "{}"],
181        )?;
182        tx.rollback()
183    }
184
185    pub(crate) fn load_receipts(&self) -> Result<Vec<HttpReceipt>, ProtectError> {
186        let mut statement = self
187            .connection
188            .prepare("SELECT receipt_json FROM http_receipts ORDER BY rowid ASC")
189            .map_err(|error| ProtectError::ReceiptStore(error.to_string()))?;
190        let rows = statement
191            .query_map([], |row| row.get::<_, String>(0))
192            .map_err(|error| ProtectError::ReceiptStore(error.to_string()))?;
193
194        let mut receipts = Vec::new();
195        for row in rows {
196            let receipt_json =
197                row.map_err(|error| ProtectError::ReceiptStore(error.to_string()))?;
198            let receipt: HttpReceipt = serde_json::from_str(&receipt_json)
199                .map_err(|error| ProtectError::ReceiptStore(error.to_string()))?;
200            receipts.push(receipt);
201        }
202        Ok(receipts)
203    }
204
205    pub(crate) fn load_tool_receipts(&self) -> Result<Vec<ChioReceipt>, ProtectError> {
206        let mut statement = self
207            .connection
208            .prepare("SELECT receipt_json FROM tool_receipts ORDER BY rowid ASC")
209            .map_err(|error| ProtectError::ReceiptStore(error.to_string()))?;
210        let rows = statement
211            .query_map([], |row| row.get::<_, String>(0))
212            .map_err(|error| ProtectError::ReceiptStore(error.to_string()))?;
213
214        let mut receipts = Vec::new();
215        for row in rows {
216            let receipt_json =
217                row.map_err(|error| ProtectError::ReceiptStore(error.to_string()))?;
218            let receipt: ChioReceipt = serde_json::from_str(&receipt_json)
219                .map_err(|error| ProtectError::ReceiptStore(error.to_string()))?;
220            receipts.push(receipt);
221        }
222        Ok(receipts)
223    }
224
225    pub(crate) fn append(&mut self, receipt: &HttpReceipt) -> Result<(), ProtectError> {
226        let receipt_json = serde_json::to_string(receipt)
227            .map_err(|error| ProtectError::ReceiptStore(error.to_string()))?;
228        self.connection
229            .execute(
230                "INSERT OR REPLACE INTO http_receipts (id, receipt_json) VALUES (?1, ?2)",
231                params![receipt.id, receipt_json],
232            )
233            .map_err(|error| ProtectError::ReceiptStore(error.to_string()))?;
234        Ok(())
235    }
236
237    pub(crate) fn append_tool_receipt(
238        &mut self,
239        receipt: &ChioReceipt,
240    ) -> Result<(), ProtectError> {
241        let receipt_json = serde_json::to_string(receipt)
242            .map_err(|error| ProtectError::ReceiptStore(error.to_string()))?;
243        self.connection
244            .execute(
245                "INSERT OR REPLACE INTO tool_receipts (id, receipt_json) VALUES (?1, ?2)",
246                params![receipt.id, receipt_json],
247            )
248            .map_err(|error| ProtectError::ReceiptStore(error.to_string()))?;
249        Ok(())
250    }
251
252    pub(crate) fn load_revoked_capability_ids(&self) -> Result<HashSet<String>, ProtectError> {
253        let mut statement = self
254            .connection
255            .prepare("SELECT capability_id FROM revoked_capabilities ORDER BY rowid ASC")
256            .map_err(|error| ProtectError::ReceiptStore(error.to_string()))?;
257        let rows = statement
258            .query_map([], |row| row.get::<_, String>(0))
259            .map_err(|error| ProtectError::ReceiptStore(error.to_string()))?;
260
261        let mut capability_ids = HashSet::new();
262        for row in rows {
263            let capability_id =
264                row.map_err(|error| ProtectError::ReceiptStore(error.to_string()))?;
265            capability_ids.insert(capability_id);
266        }
267        Ok(capability_ids)
268    }
269
270    pub(crate) fn revoke_capability(&mut self, capability_id: &str) -> Result<(), ProtectError> {
271        self.connection
272            .execute(
273                "INSERT OR REPLACE INTO revoked_capabilities (capability_id) VALUES (?1)",
274                params![capability_id],
275            )
276            .map_err(|error| ProtectError::ReceiptStore(error.to_string()))?;
277        Ok(())
278    }
279}
280
281/// Bounded, TTL-keyed set of request ids claimed for a live reservation window.
282///
283/// A request id must be unique only for the lifetime of the reservation it
284/// backs: the kernel derives the durable budget-hold identity from it, so a
285/// reused id inside the window would collapse into an idempotent authorize with
286/// no fresh reservation and defeat the over-subscription guard. Once the
287/// execution-nonce TTL lapses the hold is reconciled or reaped, so the id may be
288/// reused. Each entry carries that expiry and is pruned lazily on every
289/// mutation, bounding the set to the reservations opened within one TTL window
290/// instead of growing without limit.
291pub(crate) struct MintedRequestIdWindow {
292    ttl_secs: i64,
293    expiries: HashMap<String, i64>,
294}
295
296impl MintedRequestIdWindow {
297    pub(crate) fn new(ttl_secs: u64) -> Self {
298        Self {
299            ttl_secs: ttl_secs as i64,
300            expiries: HashMap::new(),
301        }
302    }
303
304    /// Claim `request_id` for a reservation opening at `now`. Prunes expired
305    /// entries first, then admits the id only when it is not already live inside
306    /// its window. Returns `false` for a reuse inside a live window, which the
307    /// caller maps to a fail-closed 409.
308    pub(crate) fn claim(&mut self, request_id: &str, now: i64) -> bool {
309        self.prune(now);
310        if self.expiries.contains_key(request_id) {
311            return false;
312        }
313        self.expiries
314            .insert(request_id.to_string(), now.saturating_add(self.ttl_secs));
315        true
316    }
317
318    /// Release a claimed id. Called when the authorization placed no durable
319    /// hold (denied, pending, or errored) so a failed attempt does not
320    /// permanently burn the id.
321    pub(crate) fn release(&mut self, request_id: &str) {
322        self.expiries.remove(request_id);
323    }
324
325    fn prune(&mut self, now: i64) {
326        self.expiries.retain(|_, expiry| *expiry > now);
327    }
328
329    #[cfg(test)]
330    pub(crate) fn len(&self) -> usize {
331        self.expiries.len()
332    }
333}
334
335/// Shared proxy state.
336pub(crate) struct ProxyState {
337    pub(crate) evaluator: RequestEvaluator,
338    pub(crate) signer_keypair: Keypair,
339    pub(crate) upstream: String,
340    pub(crate) http_client: reqwest::Client,
341    pub(crate) egress_contract: HttpEgressContract,
342    pub(crate) approval_admin: ApprovalAdmin,
343    pub(crate) receipt_log: Mutex<ReceiptLog>,
344    pub(crate) tool_receipt_log: Mutex<ToolReceiptLog>,
345    pub(crate) receipt_store: Option<Mutex<SqliteReceiptStore>>,
346    /// Revocation store shared with the embedded kernel. With a receipt database
347    /// it is the durable sibling file, so releases persist and a sibling replica
348    /// on the same volume observes them even though its in-memory set is loaded
349    /// once at boot and never reloaded. In ephemeral mode it is an in-memory
350    /// store, so a release is still honored in-process rather than leaving the
351    /// token live until it expires.
352    pub(crate) revocation_store: Option<Arc<dyn chio_kernel::RevocationStore>>,
353    pub(crate) revoked_capability_ids: Mutex<HashSet<String>>,
354    pub(crate) trusted_capability_issuers: Vec<PublicKey>,
355    pub(crate) trusted_receipt_signers: Vec<PublicKey>,
356    pub(crate) sidecar_control_token: Option<String>,
357    pub(crate) budget_store: Option<Arc<dyn chio_kernel::budget_store::BudgetStore>>,
358    /// Whether the configured `budget_store` implements the pre-execution hold
359    /// APIs the mediated reservation path depends on. `true` for the local SQLite
360    /// store, `false` for the remote control-plane store (which forwards only
361    /// charge/reverse/reconcile and cannot persist a durable reserved hold). The
362    /// mediated `/v1/evaluate` and `/v1/reconcile` routes reject fail-closed when
363    /// this is `false`, rather than mint a reserved nonce that can never be
364    /// reconciled by nonce or reclaimed by the TTL reaper.
365    pub(crate) mediation_hold_capable: bool,
366    /// The process-lifetime kernel-mediation authority, built once when a budget
367    /// store is configured. Held behind a `Mutex` because admitting the
368    /// caller-named tool server (registration) needs `&mut self`, and reused
369    /// across requests so the approval-token and DPoP replay stores stay
370    /// authoritative, and so the nonce it mints on `/v1/evaluate` is the one it
371    /// verifies and consumes on `/v1/reconcile`.
372    pub(crate) mediation_kernel: Option<Mutex<chio_kernel::ChioKernel>>,
373    /// Request ids claimed for a live reservation window on `/v1/evaluate`. The
374    /// kernel derives the durable budget hold identity from the request id, so
375    /// each id is admitted at most once inside its window; a reuse is rejected
376    /// fail-closed (409) to preserve the over-subscription guard. Entries expire
377    /// with the reservation (execution-nonce) TTL and are pruned lazily, so the
378    /// set stays bounded rather than growing on every request.
379    pub(crate) minted_request_ids: Mutex<MintedRequestIdWindow>,
380    /// Retained `JoinHandle` for the reserved-hold reaper task. Held so the
381    /// reaper can be aborted when the server stops accepting; a dropped
382    /// `JoinHandle` only detaches the task (it keeps running) rather than
383    /// aborting it. `None` until the reaper is spawned (and when no mediation
384    /// kernel is configured, since nothing reserves holds).
385    pub(crate) reaper_handle: Mutex<Option<tokio::task::JoinHandle<()>>>,
386    pub(crate) allow_advisory: bool,
387    pub(crate) receipt_backend: &'static str,
388    pub(crate) revocation_backend: &'static str,
389}
390
391impl ProxyState {
392    /// Whether a capability has been revoked. The in-memory set is loaded once at
393    /// boot, so a revocation a sibling replica recorded after this process
394    /// started is only visible in the shared durable store; consult it as well.
395    /// Fails closed: if the durable store cannot be queried, treat the capability
396    /// as revoked rather than admit one that may have been released.
397    pub(crate) async fn capability_is_revoked(&self, capability_id: &str) -> bool {
398        if self
399            .revoked_capability_ids
400            .lock()
401            .await
402            .contains(capability_id)
403        {
404            return true;
405        }
406        if let Some(revocation_store) = &self.revocation_store {
407            match revocation_store.is_revoked(capability_id) {
408                Ok(false) => {}
409                Ok(true) => return true,
410                Err(error) => {
411                    warn!("failed to query durable revocation store: {error}");
412                    return true;
413                }
414            }
415        }
416        false
417    }
418}
419
420impl ProxyState {
421    /// Dependency-aware readiness for the `/chio/health` probe.
422    ///
423    /// Unlike liveness, this reports the state of the runtime dependencies the
424    /// sidecar needs to serve honestly. When the durable receipt store's supervised
425    /// commit writer has stopped serving, every mediated call would be denied fail
426    /// closed, so readiness reports unhealthy and a platform probe pulls the instance
427    /// from rotation rather than routing traffic to a sidecar that can only deny.
428    pub(crate) async fn readiness_status(&self) -> SidecarStatus {
429        if let Some(store) = &self.receipt_store {
430            let store = store.lock().await;
431            if !store.is_reachable() {
432                return SidecarStatus::Unhealthy;
433            }
434        }
435        SidecarStatus::Healthy
436    }
437}
438
439/// The protect proxy.
440pub struct ProtectProxy {
441    config: ProtectConfig,
442    /// Operator-configured payment rail for the kernel-mediated authorization
443    /// path. Installed on the mediation kernel so a governed `MustPrepay`
444    /// (x402/ACP) quote is authorized before a reserved nonce is minted. `None`
445    /// by default, which keeps governed `MustPrepay` denied fail-closed: only a
446    /// configured adapter enables prepayment.
447    payment_adapter: Option<Box<dyn chio_kernel::PaymentAdapter>>,
448}
449
450impl ProtectProxy {
451    pub fn new(config: ProtectConfig) -> Self {
452        Self {
453            config,
454            payment_adapter: None,
455        }
456    }
457
458    /// Install the operator's payment adapter for the kernel-mediated route.
459    ///
460    /// The sidecar CLI resolves this from the operator's payment configuration
461    /// and threads it here before `run`. With an adapter installed, an approved
462    /// governed `MustPrepay`/x402 request authorizes (the quote is prepaid before
463    /// a reserved nonce is minted); with `None` it stays denied fail-closed.
464    #[must_use]
465    pub fn with_payment_adapter(
466        mut self,
467        payment_adapter: Option<Box<dyn chio_kernel::PaymentAdapter>>,
468    ) -> Self {
469        self.payment_adapter = payment_adapter;
470        self
471    }
472
473    async fn load_spec_content(&self) -> Result<String, ProtectError> {
474        if let Some(spec_content) = &self.config.spec_content {
475            return Ok(spec_content.clone());
476        }
477        if let Some(spec_path) = &self.config.spec_path {
478            return load_spec_from_file(spec_path);
479        }
480        discover_spec(&self.config.upstream).await
481    }
482
483    /// Build the route table from the OpenAPI spec.
484    /// Parses the spec directly to preserve path and method information.
485    fn build_routes(spec_content: &str) -> Result<Vec<RouteEntry>, ProtectError> {
486        let spec = chio_openapi::OpenApiSpec::parse(spec_content)?;
487        let mut routes = Vec::new();
488
489        for (path, path_item) in &spec.paths {
490            for (method_str, operation) in &path_item.operations {
491                let method = match method_str.as_str() {
492                    "GET" => HttpMethod::Get,
493                    "POST" => HttpMethod::Post,
494                    "PUT" => HttpMethod::Put,
495                    "PATCH" => HttpMethod::Patch,
496                    "DELETE" => HttpMethod::Delete,
497                    "HEAD" => HttpMethod::Head,
498                    "OPTIONS" => HttpMethod::Options,
499                    _ => continue,
500                };
501
502                let extensions = ChioExtensions::from_operation(&operation.raw);
503                let policy = DefaultPolicy::for_method_with_extensions(method, &extensions);
504                routes.push(RouteEntry {
505                    pattern: path.clone(),
506                    method,
507                    operation_id: operation.operation_id.clone(),
508                    policy,
509                });
510            }
511        }
512
513        Ok(routes)
514    }
515
516    /// Start the proxy server. This blocks until the server shuts down.
517    pub async fn run(self) -> Result<(), ProtectError> {
518        self.run_with_observer(|_| {}).await
519    }
520
521    /// Start the proxy server, invoking `observer` once the listener is
522    /// bound (with the resolved local `SocketAddr`).
523    ///
524    /// Used by `chio start` so the friendly banner can report the actual
525    /// bound port when the operator passes `--listen 127.0.0.1:0`. The
526    /// observer fires before `axum::serve` enters its accept loop, so
527    /// callers can forward the address to stdout, write a sentinel file,
528    /// or signal readiness over an out-of-band channel.
529    pub async fn run_with_observer<F>(self, observer: F) -> Result<(), ProtectError>
530    where
531        F: FnOnce(SocketAddr),
532    {
533        // Durable-by-default: a missing receipt store means in-memory receipts
534        // and revocations that are lost on every restart, so refuse to start
535        // unless the embedder explicitly opted into ephemeral operation. This
536        // mirrors the CLI boot gate for library callers that construct
537        // `ProtectConfig` directly and would otherwise silently lose audit
538        // evidence.
539        //
540        // An in-memory SQLite path (`:memory:` or a `file:...?mode=memory` URI)
541        // opens a database that vanishes on restart just like a missing path, so
542        // it is filtered out here. The gate and every store opened below key off
543        // this durable path; treating an in-memory path as durable would open
544        // in-memory stores yet advertise a durable receipt backend and silently
545        // lose audit evidence.
546        let durable_receipt_db: Option<&str> = self
547            .config
548            .receipt_db
549            .as_deref()
550            .filter(|path| !chio_store_sqlite::is_in_memory_sqlite_path(path));
551
552        if durable_receipt_db.is_none() && !self.config.allow_ephemeral_receipts {
553            return Err(ProtectError::Config(
554                "refusing to start without a durable receipt store: set receipt_db to a durable \
555                 SQLite path, or set allow_ephemeral_receipts to run with in-memory receipts that \
556                 are lost on every restart"
557                    .to_string(),
558            ));
559        }
560
561        if durable_receipt_db.is_some() {
562            chio_store_sqlite::SqliteAuthorityStore::ensure_serving_supported()
563                .map_err(|error| ProtectError::ReceiptStore(error.to_string()))?;
564        }
565
566        let spec_content = self.load_spec_content().await?;
567        let routes = Self::build_routes(&spec_content)?;
568        let route_count = routes.len();
569
570        let keypair = match &self.config.signer_seed_hex {
571            Some(seed_hex) => Keypair::from_seed_hex(seed_hex)
572                .map_err(|error| ProtectError::Config(error.to_string()))?,
573            None => Keypair::generate(),
574        };
575        let policy_hash = chio_core_types::sha256_hex(spec_content.as_bytes());
576
577        // Open the durable receipt store first so it owns the shared sidecar
578        // file's provenance anchor; the approval store then co-locates onto that
579        // file. Opening receipt-first fails closed on a path mistargeted at a
580        // foreign approval database: it carries no receipt anchor, so the receipt
581        // store refuses it here instead of adopting it and commingling receipt
582        // tables into another store's file.
583        let durable_receipt_store: Option<Arc<dyn chio_kernel::ReceiptStore>> =
584            match durable_receipt_db {
585                Some(path) => Some(Arc::new(
586                    chio_store_sqlite::SqliteReceiptStore::open(path)
587                        .map_err(|error| ProtectError::ReceiptStore(error.to_string()))?,
588                )),
589                None => None,
590            };
591
592        let approval_store: Arc<dyn ApprovalStore> = if let Some(path) = durable_receipt_db {
593            Arc::new(
594                SqliteApprovalStore::open_colocated_with_receipt_store(path)
595                    .map_err(|error| ProtectError::ReceiptStore(error.to_string()))?,
596            )
597        } else {
598            Arc::new(InMemoryApprovalStore::new())
599        };
600        let threshold_collector_store: Arc<dyn ThresholdApprovalCollectorStore> =
601            if let Some(path) = durable_receipt_db {
602                Arc::new(
603                    SqliteApprovalStore::open_colocated_with_receipt_store(path)
604                        .map_err(|error| ProtectError::ReceiptStore(error.to_string()))?,
605                )
606            } else {
607                Arc::new(InMemoryThresholdApprovalCollectorStore::new())
608            };
609        let threshold_collector = ThresholdApprovalCollector::new(
610            threshold_collector_store,
611            policy_hash.clone(),
612            vec![keypair.public_key()],
613        );
614
615        let mut trusted_capability_issuers = self.config.trusted_capability_issuers.clone();
616        let signer_public_key = keypair.public_key();
617        if !trusted_capability_issuers.contains(&signer_public_key) {
618            trusted_capability_issuers.push(signer_public_key.clone());
619        }
620        let trusted_receipt_signers = vec![signer_public_key];
621
622        // The revocation store lives in a sibling file so a revoked capability
623        // survives a restart. In ephemeral mode there is no durable file, but a
624        // shared in-memory store still makes a release effective for the running
625        // process: the same handle backs the embedded kernel's mediated checks
626        // and the sidecar's release endpoint, so a token can be revoked in-process
627        // rather than staying live until it expires.
628        let revocation_store: Option<Arc<dyn chio_kernel::RevocationStore>> =
629            match durable_receipt_db {
630                Some(path) => Some(Arc::new(
631                    chio_store_sqlite::SqliteRevocationStore::open(revocation_sibling_path(path))
632                        .map_err(|error| ProtectError::ReceiptStore(error.to_string()))?,
633                )),
634                None => Some(Arc::new(chio_kernel::InMemoryRevocationStore::new())),
635            };
636
637        let durable_admission = match durable_receipt_db {
638            Some(path) => {
639                let (database, lock_root) = authority_sibling_paths(path);
640                prepare_authority_lock_root(&lock_root)?;
641                chio_store_sqlite::SqliteAuthorityStore::provision(&database, &lock_root)
642                    .map_err(|error| ProtectError::ReceiptStore(error.to_string()))?;
643                let authority =
644                    chio_store_sqlite::SqliteAuthorityStore::open_serving(&database, &lock_root)
645                        .map_err(|error| ProtectError::ReceiptStore(error.to_string()))?;
646                Some(DurableAdmissionStores {
647                    store: Arc::new(authority.admission_operation_store()),
648                    outcome_store: Arc::new(authority.tool_outcome_store()),
649                    fence: authority.mutation_fence(),
650                })
651            }
652            None => None,
653        };
654
655        let evaluator = RequestEvaluator::new_with_durable_stores_and_admission(
656            routes,
657            keypair.clone(),
658            policy_hash,
659            Arc::clone(&approval_store),
660            self.config.trusted_capability_issuers.clone(),
661            durable_receipt_store,
662            revocation_store.clone(),
663            durable_admission.clone(),
664            self.config.allow_ephemeral_receipts,
665        )
666        .map_err(|error| ProtectError::Config(error.to_string()))?;
667        let receipt_backend = evaluator.receipt_backend();
668        let revocation_backend = evaluator.revocation_backend();
669
670        let (receipt_log, tool_receipt_log, receipt_store, mut revoked_capability_ids) =
671            if let Some(path) = &self.config.receipt_db {
672                let store = SqliteReceiptStore::open(path)?;
673                let receipts = store.load_receipts()?;
674                let tool_receipts = store.load_tool_receipts()?;
675                let revoked_capability_ids = store.load_revoked_capability_ids()?;
676                (
677                    ReceiptLog { receipts },
678                    ToolReceiptLog {
679                        receipts: tool_receipts,
680                    },
681                    Some(Mutex::new(store)),
682                    revoked_capability_ids,
683                )
684            } else {
685                (
686                    ReceiptLog {
687                        receipts: Vec::new(),
688                    },
689                    ToolReceiptLog {
690                        receipts: Vec::new(),
691                    },
692                    None,
693                    HashSet::new(),
694                )
695            };
696
697        // Enforce operator revocations recorded through the durable revocation
698        // store that `chio trust revoke --revocation-db <path>` writes. Merging
699        // them into the shared revoked set covers every path that consults it
700        // (mediated `/v1/evaluate`, validate, proxy, advisory) uniformly. This
701        // load is fail-closed: `load_revocation_db_ids` returns an error and the
702        // sidecar refuses to start if the configured store cannot be read.
703        if let Some(path) = self.config.revocation_db.as_deref() {
704            let durable = load_revocation_db_ids(&self.config)?;
705            let loaded = durable.len();
706            revoked_capability_ids.extend(durable);
707            info!(
708                revocation_db = path,
709                loaded,
710                enforced = revoked_capability_ids.len(),
711                "chio api protect: loaded durable revocations from --revocation-db; \
712                 enforced on /v1/evaluate and every revoked-capability path. \
713                 Revocations recorded after startup are not observed here: they \
714                 require a sidecar restart or the in-process \
715                 /v1/capabilities/release (or --control-url) channel"
716            );
717        }
718
719        let egress_contract = default_upstream_egress_contract(&self.config.upstream)?;
720        let http_client = client_builder_with_contract(&egress_contract)
721            .timeout(self.config.upstream_request_timeout)
722            .build()?;
723        let configured_budget_store = build_budget_store(&self.config)?;
724        let mediation_hold_capable = configured_budget_store
725            .as_ref()
726            .map(|configured| configured.hold_capable)
727            .unwrap_or(false);
728        let budget_store = configured_budget_store.map(|configured| configured.store);
729
730        // Automatic reconcile/reverse of open holds requires the durable receipt
731        // log (ADR-0013) to build the realized-spend arbitration map. Without
732        // that map, calling reap_orphaned_holds with an empty map would reverse
733        // every open hold, enabling double-spend: a hold left open by a crash
734        // after the spend but before reconcile represents real spent budget.
735        // Holds are left reserved (fail-closed) until receipt-log arbitration
736        // is wired at this startup point. Use reap_orphaned_holds via the
737        // control plane with a realized-spend map from the durable receipt log
738        // to reconcile crash-orphaned holds.
739        if let Some(store) = budget_store.as_ref() {
740            match store.count_open_holds() {
741                Ok(0) => {}
742                Ok(count) => {
743                    warn!(
744                        count,
745                        "startup: open budget hold(s) left reserved pending \
746                         receipt-log arbitration; automatic reconcile requires \
747                         the durable receipt log (ADR-0013) arbitration map"
748                    );
749                }
750                Err(error) => {
751                    warn!("startup: failed to count open budget holds: {error}");
752                }
753            }
754        }
755
756        // Build the kernel-mediation authority once, for the process lifetime, so
757        // the approval-token and DPoP replay stores it carries stay authoritative
758        // across `/v1/evaluate` requests and the nonce it mints is the one it
759        // verifies and consumes on `/v1/reconcile`. It exists exactly when a
760        // budget store is configured; without one, `/v1/evaluate` and
761        // `/v1/reconcile` deny fail-closed.
762        let payment_adapter = self.payment_adapter;
763        let mediation_kernel = match budget_store.as_ref() {
764            Some(store) => Some(Mutex::new(build_mediation_kernel(
765                &keypair,
766                Arc::clone(store),
767                &trusted_capability_issuers,
768                Vec::new(),
769                payment_adapter,
770                durable_admission,
771            )?)),
772            None => None,
773        };
774
775        let state = Arc::new(ProxyState {
776            evaluator,
777            signer_keypair: keypair,
778            upstream: self.config.upstream.clone(),
779            http_client,
780            egress_contract,
781            approval_admin: ApprovalAdmin::with_threshold_collector(
782                approval_store,
783                threshold_collector,
784            ),
785            receipt_log: Mutex::new(receipt_log),
786            tool_receipt_log: Mutex::new(tool_receipt_log),
787            receipt_store,
788            revocation_store,
789            revoked_capability_ids: Mutex::new(revoked_capability_ids),
790            trusted_capability_issuers,
791            trusted_receipt_signers,
792            sidecar_control_token: self.config.sidecar_control_token.clone(),
793            budget_store,
794            mediation_hold_capable,
795            mediation_kernel,
796            minted_request_ids: Mutex::new(MintedRequestIdWindow::new(
797                chio_kernel::DEFAULT_EXECUTION_NONCE_TTL_SECS,
798            )),
799            reaper_handle: Mutex::new(None),
800            allow_advisory: self.config.allow_advisory,
801            receipt_backend,
802            revocation_backend,
803        });
804
805        // Release expired, unreconciled reserved budget holds on an interval so a
806        // caller that authorizes but never reconciles does not permanently burn
807        // budget. The reaper's JoinHandle is retained on the shared state and
808        // aborted once the server stops accepting (below), bounding the task's
809        // lifetime to the server's.
810        spawn_reserved_hold_reaper(&state).await;
811
812        let app = build_app(Arc::clone(&state));
813
814        let listener = tokio::net::TcpListener::bind(&self.config.listen_addr)
815            .await
816            .map_err(|e| {
817                ProtectError::Config(format!("cannot bind {}: {e}", self.config.listen_addr))
818            })?;
819
820        let local_addr = listener.local_addr().map_err(|error| {
821            ProtectError::Config(format!("cannot resolve bound address: {error}"))
822        })?;
823
824        info!(
825            has_budget_store = state.budget_store.is_some(),
826            "chio api protect: mediation layer ready"
827        );
828        info!(
829            "chio api protect: proxying {} routes to {} on {}",
830            route_count, self.config.upstream, local_addr
831        );
832
833        observer(local_addr);
834
835        // No generic request timeout: every proxied call writes its receipt
836        // synchronously in the handler after the upstream hop returns, and that
837        // hop is already bounded by the configured upstream timeout. An outer
838        // timeout layer would drop the handler while it awaits the upstream,
839        // skipping receipt finalization for a call that may already have reached
840        // the upstream. The drain window is held a margin above that upstream
841        // ceiling so an in-flight hop is receipted before a forced drain closes
842        // it. Body size, concurrency, and the connection cap still apply.
843        let hygiene = ServeHygieneConfig {
844            request_timeout: None,
845            drain_timeout: proxy_drain_timeout(self.config.upstream_request_timeout),
846            ..ServeHygieneConfig::default()
847        };
848        let app = apply_server_hygiene(app, &hygiene);
849        let controller = ShutdownController::install();
850        // Cap simultaneously accepted connections at the accept loop so a slow or
851        // idle connection flood cannot exhaust file descriptors before any request
852        // reaches the concurrency limit. The peer address stays available to the
853        // sidecar-control loopback/bearer checks via `CappedPeerAddr`.
854        let listener =
855            MaxConnListener::new(listener, hygiene.max_connections.unwrap_or(usize::MAX));
856        let server = axum::serve(
857            listener,
858            app.into_make_service_with_connect_info::<CappedPeerAddr>(),
859        )
860        .with_graceful_shutdown(controller.signalled());
861
862        // Every proxied call writes its receipt synchronously inside the request
863        // handler, so completing the in-flight requests during the drain is the
864        // whole durability guarantee: there is nothing queued to flush afterward.
865        let serve_result = run_until_drained(
866            server,
867            controller.subscribe(),
868            hygiene.drain_timeout,
869            async { Ok::<(), String>(()) },
870        )
871        .await
872        .map(|_outcome| ())
873        .map_err(protect_serve_error);
874
875        // The reaper holds a clone of the shared state; abort it now the server
876        // has stopped so the task does not outlive the serving lifetime (a
877        // dropped JoinHandle would only detach it, leaving it running).
878        if let Some(handle) = state.reaper_handle.lock().await.take() {
879            handle.abort();
880        }
881
882        serve_result?;
883
884        Ok(())
885    }
886
887    /// Build routes from spec content for testing.
888    pub fn routes_from_spec(spec_content: &str) -> Result<Vec<RouteEntry>, ProtectError> {
889        Self::build_routes(spec_content)
890    }
891}
892
893#[cfg(test)]
894mod proxy_builder_tests {
895    use super::*;
896
897    fn minimal_config() -> ProtectConfig {
898        ProtectConfig {
899            upstream: "http://127.0.0.1:1".to_string(),
900            spec_content: Some("{}".to_string()),
901            spec_path: None,
902            listen_addr: "127.0.0.1:0".to_string(),
903            receipt_db: None,
904            allow_ephemeral_receipts: true,
905            sidecar_control_token: None,
906            signer_seed_hex: None,
907            trusted_capability_issuers: Vec::new(),
908            control_url: None,
909            control_token: None,
910            budget_db: None,
911            revocation_db: None,
912            require_nonce: false,
913            allow_advisory: false,
914            upstream_request_timeout: crate::DEFAULT_UPSTREAM_REQUEST_TIMEOUT,
915        }
916    }
917
918    #[test]
919    fn with_payment_adapter_threads_adapter_and_defaults_none() {
920        // The sidecar CLI threads the operator's resolved payment adapter here so
921        // the proxy installs it on the mediation kernel and governed MustPrepay
922        // can be prepaid. Absent the builder call the adapter defaults to `None`,
923        // which keeps governed MustPrepay denied fail-closed.
924        let default = ProtectProxy::new(minimal_config());
925        assert!(
926            default.payment_adapter.is_none(),
927            "a proxy defaults to no payment adapter, keeping governed MustPrepay denied"
928        );
929
930        let configured = ProtectProxy::new(minimal_config()).with_payment_adapter(Some(Box::new(
931            chio_kernel::payment::SimPaymentAdapter::new(),
932        )));
933        assert!(
934            configured.payment_adapter.is_some(),
935            "with_payment_adapter must thread the configured adapter into the proxy"
936        );
937    }
938}
939
940#[cfg(all(test, windows))]
941mod windows_authority_tests {
942    use super::*;
943    use std::sync::atomic::{AtomicBool, Ordering};
944
945    #[tokio::test]
946    async fn durable_startup_rejects_windows_before_api_protect_mutation(
947    ) -> Result<(), Box<dyn std::error::Error>> {
948        let directory = tempfile::tempdir()?;
949        let state_parent = directory.path().join("state");
950        let receipt_database = state_parent.join("receipts.sqlite3");
951        let receipt_database_string = receipt_database.to_string_lossy().into_owned();
952        let (authority_database, authority_lock_root) =
953            authority_sibling_paths(&receipt_database_string);
954        let missing_spec = directory.path().join("missing-openapi.json");
955        let observer_called = AtomicBool::new(false);
956
957        let result = ProtectProxy::new(ProtectConfig {
958            upstream: "http://127.0.0.1:1".to_string(),
959            spec_content: None,
960            spec_path: Some(missing_spec.to_string_lossy().into_owned()),
961            listen_addr: "127.0.0.1:0".to_string(),
962            receipt_db: Some(receipt_database_string),
963            allow_ephemeral_receipts: false,
964            sidecar_control_token: None,
965            signer_seed_hex: None,
966            trusted_capability_issuers: Vec::new(),
967            control_url: None,
968            control_token: None,
969            budget_db: None,
970            revocation_db: None,
971            require_nonce: false,
972            allow_advisory: false,
973            upstream_request_timeout: crate::DEFAULT_UPSTREAM_REQUEST_TIMEOUT,
974        })
975        .run_with_observer(|_| observer_called.store(true, Ordering::SeqCst))
976        .await;
977
978        let error = match result {
979            Ok(()) => {
980                return Err(std::io::Error::other(
981                    "Windows durable API-protect startup unexpectedly succeeded",
982                )
983                .into());
984            }
985            Err(error) => error,
986        };
987
988        assert!(
989            matches!(
990                &error,
991                ProtectError::ReceiptStore(message)
992                    if message.contains(
993                        "sqlite authority serving requires Unix file identity and positioned I/O"
994                    )
995            ),
996            "the platform preflight must fail before attempting to load the missing spec: {error}"
997        );
998        assert!(!observer_called.load(Ordering::SeqCst));
999        assert!(!state_parent.exists());
1000        assert!(!receipt_database.exists());
1001        assert!(!authority_database.exists());
1002        assert!(!authority_lock_root.exists());
1003        Ok(())
1004    }
1005}
1006
1007fn protect_serve_error(error: ServeError) -> ProtectError {
1008    match error {
1009        ServeError::Io(source) => ProtectError::Io(source),
1010        ServeError::Flush(message) => ProtectError::Io(std::io::Error::other(message)),
1011    }
1012}
1013
1014#[cfg(test)]
1015mod durability_tests {
1016    use super::{authority_sibling_paths, revocation_sibling_path, SqliteReceiptStore};
1017    use chio_test_support::prelude::*;
1018
1019    #[test]
1020    fn revocation_sibling_path_appends_suffix_to_a_plain_path() {
1021        assert_eq!(
1022            revocation_sibling_path("/var/lib/chio/receipts.db"),
1023            "/var/lib/chio/receipts.db.revocations"
1024        );
1025    }
1026
1027    #[test]
1028    fn revocation_sibling_path_keeps_the_uri_query_after_the_suffix() {
1029        // The suffix must land on the database filename, not inside the query,
1030        // so the revocation store opens a distinct sibling database rather than
1031        // a bad `mode=rwc.revocations` URI or the receipt database itself.
1032        assert_eq!(
1033            revocation_sibling_path("file:/var/lib/chio/receipts.db?mode=rwc"),
1034            "file:/var/lib/chio/receipts.db.revocations?mode=rwc"
1035        );
1036    }
1037
1038    #[test]
1039    fn authority_sibling_paths_resolve_the_receipt_uri_to_filesystem_paths() {
1040        let (database, lock_root) =
1041            authority_sibling_paths("file:/var/lib/chio/receipts.db?mode=rwc");
1042        assert_eq!(
1043            database,
1044            std::path::Path::new("/var/lib/chio/receipts.db.authority-locks/authority.db")
1045        );
1046        assert_eq!(
1047            lock_root,
1048            std::path::Path::new("/var/lib/chio/receipts.db.authority-locks")
1049        );
1050    }
1051
1052    #[test]
1053    fn http_receipt_store_open_configures_wal_and_a_busy_timeout() {
1054        let mut path = std::env::temp_dir();
1055        path.push(format!("chio-http-receipts-{}.db", uuid::Uuid::now_v7()));
1056        let path_str = path.to_string_lossy().into_owned();
1057
1058        let store = SqliteReceiptStore::open(&path_str).test_unwrap();
1059
1060        let busy_timeout: i64 = store
1061            .connection
1062            .query_row("PRAGMA busy_timeout", [], |row| row.get(0))
1063            .test_unwrap();
1064        assert!(
1065            busy_timeout >= 5000,
1066            "the http receipt writer must share the receipt store busy timeout, got {busy_timeout}"
1067        );
1068
1069        let journal_mode: String = store
1070            .connection
1071            .query_row("PRAGMA journal_mode", [], |row| row.get(0))
1072            .test_unwrap();
1073        assert!(
1074            journal_mode.eq_ignore_ascii_case("wal"),
1075            "the http receipt writer must run in WAL mode, got {journal_mode}"
1076        );
1077
1078        let _ = std::fs::remove_file(&path);
1079    }
1080}
1081
1082#[cfg(test)]
1083mod tests {
1084    use super::{proxy_drain_timeout, PROXY_DRAIN_MARGIN};
1085    use crate::DEFAULT_UPSTREAM_REQUEST_TIMEOUT;
1086    use chio_http_serve::DEFAULT_DRAIN_TIMEOUT;
1087    use std::time::Duration;
1088
1089    /// The drain window must always outlast the upstream hop ceiling so a hop that
1090    /// is still in flight at shutdown resolves and records its receipt before the
1091    /// forced drain closes the connection. This must hold for any configured
1092    /// timeout, including values raised above the default drain window.
1093    #[test]
1094    fn drain_window_always_outlasts_the_configured_upstream_timeout() {
1095        for secs in [1u64, 20, 30, 60, 300] {
1096            let upstream = Duration::from_secs(secs);
1097            assert!(
1098                proxy_drain_timeout(upstream) > upstream,
1099                "drain window must outlast a {secs}s upstream timeout"
1100            );
1101            assert_eq!(proxy_drain_timeout(upstream), upstream + PROXY_DRAIN_MARGIN);
1102        }
1103    }
1104
1105    /// The default configuration keeps the historical 20s hop / 25s drain pairing,
1106    /// so making the timeout configurable does not shift default behavior.
1107    #[test]
1108    fn default_upstream_timeout_preserves_the_default_drain_window() {
1109        assert_eq!(
1110            proxy_drain_timeout(DEFAULT_UPSTREAM_REQUEST_TIMEOUT),
1111            DEFAULT_DRAIN_TIMEOUT
1112        );
1113    }
1114}