Skip to main content

chio_http_core/
regulatory_api.rs

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