Skip to main content

chio_http_core/
regulatory_api.rs

1//! Phase 19.3 -- read-only regulatory API over the receipt store.
2//!
3//! This module exposes a substrate-agnostic handler that accepts a
4//! filter description, pulls receipts from a pluggable store, and
5//! wraps the result in a signed envelope. Every response is a
6//! [`SignedExportEnvelope`] signed with the kernel's receipt-signing
7//! keypair, so regulators can verify every export against the
8//! kernel's public key.
9//!
10//! `chio-http-core` does not embed an HTTP server; substrate adapters
11//! wire [`handle_regulatory_receipts`] into their framework-native
12//! routing layer and forward query-string fields through
13//! [`RegulatoryReceiptsQuery`].
14
15use chio_core_types::canonical::canonical_json_bytes;
16use chio_core_types::crypto::{Keypair, PublicKey};
17use chio_core_types::receipt::{ChioReceipt, SignedExportEnvelope};
18use serde::{Deserialize, Serialize};
19
20/// Stable schema identifier for regulatory receipt exports.
21pub const REGULATORY_RECEIPT_EXPORT_SCHEMA: &str = "chio.regulatory.receipt-export.v1";
22
23/// Maximum number of receipts returned by one regulatory export.
24pub const MAX_REGULATORY_EXPORT_LIMIT: usize = 200;
25
26/// Body of a regulatory receipt export. Wrapped in a
27/// `SignedExportEnvelope` so the signature covers every field of the
28/// body.
29#[derive(Debug, Clone, Serialize, Deserialize)]
30#[serde(rename_all = "camelCase")]
31pub struct RegulatoryReceiptExport {
32    /// Stable schema identifier.
33    pub schema: String,
34    /// Agent subject that was queried. `None` means "all agents".
35    #[serde(default, skip_serializing_if = "Option::is_none")]
36    pub agent_id: Option<String>,
37    /// Unix timestamp (seconds) the client used as the lower bound.
38    #[serde(default, skip_serializing_if = "Option::is_none")]
39    pub after: Option<u64>,
40    /// Upper timestamp bound, if the caller supplied one.
41    #[serde(default, skip_serializing_if = "Option::is_none")]
42    pub before: Option<u64>,
43    /// Total receipts that matched the query (pre-limit).
44    pub matching_receipts: u64,
45    /// Unix timestamp the export was generated at.
46    pub generated_at: u64,
47    /// The receipts themselves, ordered by seq ascending.
48    pub receipts: Vec<ChioReceipt>,
49}
50
51/// Signed envelope alias for regulatory receipt exports.
52pub type SignedRegulatoryReceiptExport = SignedExportEnvelope<RegulatoryReceiptExport>;
53
54/// Query parameters for `GET /regulatory/receipts`.
55#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
56#[serde(rename_all = "camelCase")]
57pub struct RegulatoryReceiptsQuery {
58    /// Filter by agent subject (hex-encoded Ed25519 public key).
59    #[serde(default, skip_serializing_if = "Option::is_none")]
60    pub agent: Option<String>,
61    /// Include only receipts with `timestamp >= after`.
62    #[serde(default, skip_serializing_if = "Option::is_none")]
63    pub after: Option<u64>,
64    /// Include only receipts with `timestamp <= before`.
65    #[serde(default, skip_serializing_if = "Option::is_none")]
66    pub before: Option<u64>,
67    /// Maximum rows to return (capped at
68    /// [`MAX_REGULATORY_EXPORT_LIMIT`]).
69    #[serde(default, skip_serializing_if = "Option::is_none")]
70    pub limit: Option<usize>,
71}
72
73impl RegulatoryReceiptsQuery {
74    #[must_use]
75    pub fn limit_or_default(&self) -> usize {
76        self.limit
77            .unwrap_or(MAX_REGULATORY_EXPORT_LIMIT)
78            .clamp(1, MAX_REGULATORY_EXPORT_LIMIT)
79    }
80}
81
82/// Error surface returned by [`handle_regulatory_receipts`].
83#[derive(Debug, Clone, PartialEq, Eq)]
84pub enum RegulatoryApiError {
85    /// Malformed query or body.
86    BadRequest(String),
87    /// The handler was invoked without an authorized regulator token.
88    Unauthorized,
89    /// The handler could not access the backing receipt store.
90    StoreUnavailable(String),
91    /// Canonical-JSON signing failed (unexpected).
92    Signing(String),
93}
94
95impl RegulatoryApiError {
96    /// HTTP status code for this error.
97    #[must_use]
98    pub fn status(&self) -> u16 {
99        match self {
100            Self::BadRequest(_) => 400,
101            Self::Unauthorized => 401,
102            Self::StoreUnavailable(_) => 503,
103            Self::Signing(_) => 500,
104        }
105    }
106
107    /// Stable machine-readable code.
108    #[must_use]
109    pub fn code(&self) -> &'static str {
110        match self {
111            Self::BadRequest(_) => "bad_request",
112            Self::Unauthorized => "unauthorized",
113            Self::StoreUnavailable(_) => "store_unavailable",
114            Self::Signing(_) => "signing_error",
115        }
116    }
117
118    /// Human-readable message.
119    #[must_use]
120    pub fn message(&self) -> String {
121        match self {
122            Self::BadRequest(reason) => reason.clone(),
123            Self::Unauthorized => "regulatory API access denied".to_string(),
124            Self::StoreUnavailable(reason) => reason.clone(),
125            Self::Signing(reason) => reason.clone(),
126        }
127    }
128
129    /// Wire-format body mirroring the emergency/plan handler error shape.
130    #[must_use]
131    pub fn body(&self) -> serde_json::Value {
132        serde_json::json!({
133            "error": self.code(),
134            "message": self.message(),
135        })
136    }
137}
138
139/// Pluggable source for regulatory receipt queries.
140///
141/// Substrate adapters pass a concrete implementation (usually an
142/// `chio-store-sqlite` wrapper) into [`handle_regulatory_receipts`].
143/// Keeping this as a trait avoids an `chio-http-core -> chio-store-sqlite`
144/// dependency while letting callers back the endpoint with any
145/// storage layer.
146pub trait RegulatoryReceiptSource: Send + Sync {
147    /// Return receipts matching the query. Implementations should
148    /// respect the caller's limit and return the `matching_receipts`
149    /// count independent of the limit.
150    fn query_receipts(
151        &self,
152        query: &RegulatoryReceiptsQuery,
153    ) -> Result<RegulatoryReceiptQueryResult, RegulatoryApiError>;
154}
155
156/// Raw query result handed back to the handler.
157#[derive(Debug, Clone, Default)]
158pub struct RegulatoryReceiptQueryResult {
159    /// Total receipts matching the filter (pre-limit).
160    pub matching_receipts: u64,
161    /// Receipts (length <= `limit_or_default`).
162    pub receipts: Vec<ChioReceipt>,
163}
164
165/// Authorization surface for the regulatory API.
166///
167/// The regulatory endpoint must only be reachable by caller identities
168/// that the operator has explicitly trusted. Adapters validate the
169/// caller's credential (typically an `X-Regulatory-Token` header) and
170/// hand in an authorized [`RegulatorIdentity`] on success.
171#[derive(Debug, Clone, PartialEq, Eq)]
172pub struct RegulatorIdentity {
173    /// Stable identifier for audit logging (e.g. regulator name,
174    /// agency id).
175    pub id: String,
176}
177
178/// Build, sign, and return a regulatory receipt export envelope.
179///
180/// `ChioKernel` intentionally only exposes its public key, not its
181/// private keypair. To keep the regulatory endpoint fail-closed
182/// without broadening the kernel's public API, the operator plumbs
183/// the kernel's signing keypair in alongside the kernel handle. This
184/// matches the existing evidence-export pattern. Regulators later
185/// verify the envelope against `ChioKernel::public_key()`.
186///
187/// # Parameters
188///
189/// * `source` -- pluggable receipt feed implementation.
190/// * `identity` -- caller identity (None = unauthenticated = 401).
191/// * `query` -- filter the caller sent on the URL.
192/// * `keypair` -- the kernel's receipt-signing keypair.
193/// * `now` -- unix timestamp for the `generated_at` field.
194pub fn handle_regulatory_receipts_signed(
195    source: &dyn RegulatoryReceiptSource,
196    identity: Option<&RegulatorIdentity>,
197    query: &RegulatoryReceiptsQuery,
198    keypair: &Keypair,
199    now: u64,
200) -> Result<SignedRegulatoryReceiptExport, RegulatoryApiError> {
201    let _identity = identity.ok_or(RegulatoryApiError::Unauthorized)?;
202
203    if let (Some(after), Some(before)) = (query.after, query.before) {
204        if after > before {
205            return Err(RegulatoryApiError::BadRequest(
206                "after must be <= before".to_string(),
207            ));
208        }
209    }
210
211    let raw = source.query_receipts(query)?;
212
213    let body = RegulatoryReceiptExport {
214        schema: REGULATORY_RECEIPT_EXPORT_SCHEMA.to_string(),
215        agent_id: query.agent.clone(),
216        after: query.after,
217        before: query.before,
218        matching_receipts: raw.matching_receipts,
219        generated_at: now,
220        receipts: raw.receipts,
221    };
222
223    sign_regulatory_export(body, keypair)
224}
225
226/// Sign a prebuilt export body with the kernel's keypair. Exposed so
227/// callers that have already materialized the body elsewhere (e.g. a
228/// batch job) can produce a verifiable envelope without re-running
229/// the query pipeline.
230pub fn sign_regulatory_export(
231    body: RegulatoryReceiptExport,
232    keypair: &Keypair,
233) -> Result<SignedRegulatoryReceiptExport, RegulatoryApiError> {
234    SignedExportEnvelope::sign(body, keypair)
235        .map_err(|e| RegulatoryApiError::Signing(e.to_string()))
236}
237
238/// Verify a regulatory export envelope against the kernel's public key.
239///
240/// Thin wrapper that additionally checks the schema identifier and
241/// canonical-JSON integrity of the body.
242pub fn verify_regulatory_export(
243    envelope: &SignedRegulatoryReceiptExport,
244    expected_signer: &PublicKey,
245) -> Result<bool, RegulatoryApiError> {
246    if envelope.body.schema != REGULATORY_RECEIPT_EXPORT_SCHEMA {
247        return Err(RegulatoryApiError::BadRequest(format!(
248            "unexpected schema {:?}",
249            envelope.body.schema
250        )));
251    }
252    if &envelope.signer_key != expected_signer {
253        return Ok(false);
254    }
255    // Ensure canonical-JSON is computable before asking the library to
256    // verify. Any failure here is reported as a signing error so the
257    // caller can distinguish malformed bodies from bad signatures.
258    canonical_json_bytes(&envelope.body).map_err(|e| RegulatoryApiError::Signing(e.to_string()))?;
259    envelope
260        .verify_signature()
261        .map_err(|e| RegulatoryApiError::Signing(e.to_string()))
262}
263
264#[cfg(test)]
265#[allow(clippy::unwrap_used, clippy::expect_used)]
266mod tests {
267    use super::*;
268
269    struct FixedSource {
270        result: RegulatoryReceiptQueryResult,
271    }
272
273    impl RegulatoryReceiptSource for FixedSource {
274        fn query_receipts(
275            &self,
276            _query: &RegulatoryReceiptsQuery,
277        ) -> Result<RegulatoryReceiptQueryResult, RegulatoryApiError> {
278            Ok(self.result.clone())
279        }
280    }
281
282    #[test]
283    fn signed_export_verifies_with_matching_keypair() {
284        let keypair = Keypair::generate();
285        let source = FixedSource {
286            result: RegulatoryReceiptQueryResult::default(),
287        };
288        let identity = RegulatorIdentity {
289            id: "regulator".to_string(),
290        };
291        let envelope = handle_regulatory_receipts_signed(
292            &source,
293            Some(&identity),
294            &RegulatoryReceiptsQuery::default(),
295            &keypair,
296            42,
297        )
298        .unwrap();
299
300        assert!(verify_regulatory_export(&envelope, &keypair.public_key()).unwrap());
301    }
302
303    #[test]
304    fn unauthorized_caller_is_rejected() {
305        let keypair = Keypair::generate();
306        let source = FixedSource {
307            result: RegulatoryReceiptQueryResult::default(),
308        };
309        let err = handle_regulatory_receipts_signed(
310            &source,
311            None,
312            &RegulatoryReceiptsQuery::default(),
313            &keypair,
314            0,
315        )
316        .expect_err("unauthorized must reject");
317        assert_eq!(err.status(), 401);
318    }
319
320    #[test]
321    fn stale_time_window_is_rejected() {
322        let keypair = Keypair::generate();
323        let source = FixedSource {
324            result: RegulatoryReceiptQueryResult::default(),
325        };
326        let identity = RegulatorIdentity {
327            id: "regulator".to_string(),
328        };
329        let err = handle_regulatory_receipts_signed(
330            &source,
331            Some(&identity),
332            &RegulatoryReceiptsQuery {
333                after: Some(100),
334                before: Some(50),
335                ..Default::default()
336            },
337            &keypair,
338            0,
339        )
340        .expect_err("after>before must reject");
341        assert_eq!(err.status(), 400);
342    }
343}