Skip to main content

chio_http_core/
approvals.rs

1//! HITL approval HTTP surface.
2//!
3//! Substrate-agnostic handlers for the four approval endpoints:
4//!
5//! | Method | Path                            | Handler |
6//! |--------|---------------------------------|---------|
7//! | GET    | `/approvals/pending`            | [`handle_list_pending`] |
8//! | GET    | `/approvals/{id}`               | [`handle_get_approval`] |
9//! | POST   | `/approvals/{id}/respond`       | [`handle_respond`] |
10//! | POST   | `/approvals/batch/respond`      | [`handle_batch_respond`] |
11//!
12//! Each handler accepts parsed inputs and returns a typed response so
13//! `chio-tower`, `chio-api-protect`, and hosted sidecars can serve them
14//! without agreeing on a framework. Errors carry HTTP status codes via
15//! [`ApprovalHandlerError::status`] for predictable mapping.
16
17use std::sync::Arc;
18
19use chio_core_types::capability::governance::{GovernedApprovalToken, ThresholdApprovalProposal};
20use chio_core_types::capability::threshold_approval::ThresholdApprovalRequirement;
21use chio_core_types::crypto::PublicKey;
22use chio_kernel::{
23    resume_with_decision, ApprovalDecision, ApprovalFilter, ApprovalOutcome, ApprovalRequest,
24    ApprovalStore, ApprovalStoreError, ApprovalToken, CollectedThresholdApprovalSet, KernelError,
25    ResolvedApproval, ThresholdApprovalCollector, ThresholdApprovalCollectorProposal,
26    ThresholdApprovalCollectorStoreError,
27};
28use serde::{Deserialize, Serialize};
29
30/// Errors returned by the approval handlers. Each variant maps onto a
31/// stable HTTP status so substrate adapters can relay the code without
32/// re-interpreting the semantics.
33#[derive(Debug, Clone)]
34pub enum ApprovalHandlerError {
35    /// Request body could not be parsed into the expected JSON shape.
36    BadRequest(String),
37    /// Target approval id does not exist in the store.
38    NotFound(String),
39    /// Approval was already resolved (single-response rule).
40    Conflict(String),
41    /// Replay detected: the signed token has already been consumed.
42    ReplayDetected(String),
43    /// Approval token failed binding / signature / time checks.
44    Rejected(String),
45    /// Backend store surfaced an internal error.
46    Internal(String),
47}
48
49impl ApprovalHandlerError {
50    #[must_use]
51    pub fn status(&self) -> u16 {
52        match self {
53            Self::BadRequest(_) => 400,
54            Self::NotFound(_) => 404,
55            Self::Conflict(_) => 409,
56            Self::ReplayDetected(_) => 409,
57            Self::Rejected(_) => 403,
58            Self::Internal(_) => 500,
59        }
60    }
61
62    #[must_use]
63    pub fn code(&self) -> &'static str {
64        match self {
65            Self::BadRequest(_) => "bad_request",
66            Self::NotFound(_) => "not_found",
67            Self::Conflict(_) => "conflict",
68            Self::ReplayDetected(_) => "replay_detected",
69            Self::Rejected(_) => "approval_rejected",
70            Self::Internal(_) => "internal_error",
71        }
72    }
73
74    #[must_use]
75    pub fn message(&self) -> String {
76        match self {
77            Self::BadRequest(m)
78            | Self::NotFound(m)
79            | Self::Conflict(m)
80            | Self::ReplayDetected(m)
81            | Self::Rejected(m)
82            | Self::Internal(m) => m.clone(),
83        }
84    }
85
86    #[must_use]
87    pub fn body(&self) -> serde_json::Value {
88        serde_json::json!({
89            "error": self.code(),
90            "message": self.message(),
91        })
92    }
93}
94
95impl std::fmt::Display for ApprovalHandlerError {
96    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
97        write!(f, "{}: {}", self.code(), self.message())
98    }
99}
100
101impl std::error::Error for ApprovalHandlerError {}
102
103impl From<ApprovalStoreError> for ApprovalHandlerError {
104    fn from(e: ApprovalStoreError) -> Self {
105        match e {
106            ApprovalStoreError::NotFound(m) => Self::NotFound(m),
107            ApprovalStoreError::AlreadyResolved(m) => {
108                Self::Conflict(format!("already resolved: {m}"))
109            }
110            ApprovalStoreError::Replay(m) => Self::ReplayDetected(m),
111            ApprovalStoreError::Backend(m) => Self::Internal(m),
112            ApprovalStoreError::Serialization(m) => Self::Internal(m),
113        }
114    }
115}
116
117impl From<KernelError> for ApprovalHandlerError {
118    fn from(e: KernelError) -> Self {
119        match e {
120            KernelError::ApprovalRejected(m) => {
121                if m.contains("replay") {
122                    Self::ReplayDetected(m)
123                } else {
124                    Self::Rejected(m)
125                }
126            }
127            other => Self::Internal(other.to_string()),
128        }
129    }
130}
131
132impl From<ThresholdApprovalCollectorStoreError> for ApprovalHandlerError {
133    fn from(error: ThresholdApprovalCollectorStoreError) -> Self {
134        match error {
135            ThresholdApprovalCollectorStoreError::NotFound(message) => Self::NotFound(message),
136            ThresholdApprovalCollectorStoreError::Conflict(message) => Self::Conflict(message),
137            ThresholdApprovalCollectorStoreError::Backend(message)
138            | ThresholdApprovalCollectorStoreError::Serialization(message) => {
139                Self::Internal(message)
140            }
141        }
142    }
143}
144
145/// Admin handle bound to the kernel's approval store.
146#[derive(Clone)]
147pub struct ApprovalAdmin {
148    store: Arc<dyn ApprovalStore>,
149    threshold_collector: Option<ThresholdApprovalCollector>,
150}
151
152impl std::fmt::Debug for ApprovalAdmin {
153    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
154        f.debug_struct("ApprovalAdmin").finish_non_exhaustive()
155    }
156}
157
158impl ApprovalAdmin {
159    #[must_use]
160    pub fn new(store: Arc<dyn ApprovalStore>) -> Self {
161        Self {
162            store,
163            threshold_collector: None,
164        }
165    }
166
167    #[must_use]
168    pub fn with_threshold_collector(
169        store: Arc<dyn ApprovalStore>,
170        threshold_collector: ThresholdApprovalCollector,
171    ) -> Self {
172        Self {
173            store,
174            threshold_collector: Some(threshold_collector),
175        }
176    }
177
178    #[must_use]
179    pub fn store(&self) -> &Arc<dyn ApprovalStore> {
180        &self.store
181    }
182
183    fn threshold_collector(&self) -> Result<&ThresholdApprovalCollector, ApprovalHandlerError> {
184        self.threshold_collector.as_ref().ok_or_else(|| {
185            ApprovalHandlerError::Internal(
186                "threshold approval collector is not configured".to_string(),
187            )
188        })
189    }
190}
191
192// ----- Wire shapes --------------------------------------------------
193
194/// Query parameters for `GET /approvals/pending`.
195#[derive(Debug, Clone, Default, Serialize, Deserialize)]
196pub struct PendingQuery {
197    #[serde(default, skip_serializing_if = "Option::is_none")]
198    pub subject_id: Option<String>,
199    #[serde(default, skip_serializing_if = "Option::is_none")]
200    pub tool_server: Option<String>,
201    #[serde(default, skip_serializing_if = "Option::is_none")]
202    pub tool_name: Option<String>,
203    #[serde(default, skip_serializing_if = "Option::is_none")]
204    pub not_expired_at: Option<u64>,
205    #[serde(default, skip_serializing_if = "Option::is_none")]
206    pub limit: Option<usize>,
207}
208
209impl From<PendingQuery> for ApprovalFilter {
210    fn from(q: PendingQuery) -> Self {
211        Self {
212            subject_id: q.subject_id,
213            tool_server: q.tool_server,
214            tool_name: q.tool_name,
215            not_expired_at: q.not_expired_at,
216            limit: q.limit,
217        }
218    }
219}
220
221#[derive(Debug, Clone, Serialize, Deserialize)]
222pub struct PendingListResponse {
223    pub approvals: Vec<ApprovalRequest>,
224    pub count: usize,
225}
226
227/// Body for `POST /approvals/{id}/respond`.
228#[derive(Debug, Clone, Serialize, Deserialize)]
229pub struct RespondRequest {
230    pub outcome: ApprovalOutcome,
231    #[serde(default, skip_serializing_if = "Option::is_none")]
232    pub reason: Option<String>,
233    pub approver: PublicKey,
234    pub token: GovernedApprovalToken,
235}
236
237#[derive(Debug, Clone, Serialize, Deserialize)]
238pub struct RespondResponse {
239    pub approval_id: String,
240    pub outcome: ApprovalOutcome,
241    pub resolved_at: u64,
242}
243
244#[derive(Debug, Clone, Serialize, Deserialize)]
245pub struct CreateThresholdProposalRequest {
246    pub proposal: ThresholdApprovalProposal,
247    pub requirement: ThresholdApprovalRequirement,
248    #[serde(default, skip_serializing_if = "Option::is_none")]
249    pub submitter: Option<PublicKey>,
250    #[serde(default)]
251    pub require_submitter_separation: bool,
252}
253
254#[derive(Debug, Clone, Serialize, Deserialize)]
255pub struct SubmitThresholdApprovalRequest {
256    pub token: GovernedApprovalToken,
257}
258
259/// Body for `POST /approvals/batch/respond`.
260#[derive(Debug, Clone, Serialize, Deserialize)]
261pub struct BatchRespondRequest {
262    pub decisions: Vec<BatchDecisionEntry>,
263}
264
265#[derive(Debug, Clone, Serialize, Deserialize)]
266pub struct BatchDecisionEntry {
267    pub approval_id: String,
268    pub outcome: ApprovalOutcome,
269    #[serde(default, skip_serializing_if = "Option::is_none")]
270    pub reason: Option<String>,
271    pub approver: PublicKey,
272    pub token: GovernedApprovalToken,
273}
274
275#[derive(Debug, Clone, Serialize, Deserialize)]
276pub struct BatchRespondResponse {
277    pub results: Vec<BatchRespondResult>,
278    pub summary: BatchRespondSummary,
279}
280
281#[derive(Debug, Clone, Serialize, Deserialize)]
282pub struct BatchRespondResult {
283    pub approval_id: String,
284    pub status: String,
285    #[serde(default, skip_serializing_if = "Option::is_none")]
286    pub outcome: Option<ApprovalOutcome>,
287    #[serde(default, skip_serializing_if = "Option::is_none")]
288    pub error: Option<String>,
289}
290
291#[derive(Debug, Clone, Serialize, Deserialize)]
292pub struct BatchRespondSummary {
293    pub total: usize,
294    pub approved: usize,
295    pub denied: usize,
296    pub rejected: usize,
297}
298
299// ----- Handlers -----------------------------------------------------
300
301/// `GET /approvals/pending` -- list pending approvals matching the
302/// filter. Returns a stable JSON shape.
303pub fn handle_list_pending(
304    admin: &ApprovalAdmin,
305    query: PendingQuery,
306) -> Result<PendingListResponse, ApprovalHandlerError> {
307    let filter: ApprovalFilter = query.into();
308    let approvals = admin.store.list_pending(&filter)?;
309    let count = approvals.len();
310    Ok(PendingListResponse { approvals, count })
311}
312
313/// `GET /approvals/{id}`.
314///
315/// Returns the pending record if still outstanding; otherwise returns
316/// the resolved record. Adapters may encode "resolved" via the
317/// `resolution` field so callers can tell the two states apart without
318/// extra round trips.
319#[derive(Debug, Clone, Serialize, Deserialize)]
320pub struct GetApprovalResponse {
321    #[serde(default, skip_serializing_if = "Option::is_none")]
322    pub pending: Option<ApprovalRequest>,
323    #[serde(default, skip_serializing_if = "Option::is_none")]
324    pub resolution: Option<ResolvedApproval>,
325}
326
327pub fn handle_get_approval(
328    admin: &ApprovalAdmin,
329    approval_id: &str,
330) -> Result<GetApprovalResponse, ApprovalHandlerError> {
331    let pending = admin.store.get_pending(approval_id)?;
332    let resolution = admin.store.get_resolution(approval_id)?;
333    if pending.is_none() && resolution.is_none() {
334        return Err(ApprovalHandlerError::NotFound(approval_id.to_string()));
335    }
336    Ok(GetApprovalResponse {
337        pending,
338        resolution,
339    })
340}
341
342/// `POST /approvals/{id}/respond` -- submit an approval decision.
343pub fn handle_respond(
344    admin: &ApprovalAdmin,
345    approval_id: &str,
346    body: RespondRequest,
347    now: u64,
348) -> Result<RespondResponse, ApprovalHandlerError> {
349    // The approval_id in the URL must agree with the token the human
350    // signed, otherwise the signed binding is wrong and we cannot
351    // authorize resume.
352    if body.token.request_id != approval_id {
353        return Err(ApprovalHandlerError::BadRequest(format!(
354            "approval_id {approval_id} does not match signed token request_id {}",
355            body.token.request_id
356        )));
357    }
358
359    let decision = ApprovalDecision {
360        approval_id: approval_id.to_string(),
361        outcome: body.outcome.clone(),
362        reason: body.reason,
363        approver: body.approver.clone(),
364        token: body.token,
365        received_at: now,
366    };
367
368    let outcome = resume_with_decision(admin.store.as_ref(), &decision, now)?;
369
370    // Defense-in-depth: the ApprovalToken is now consumed; exercise
371    // the replay guard immediately so operators can trust the store
372    // wrote the record.
373    let approval_token = ApprovalToken::from_decision(&decision);
374    let _ = approval_token; // consumed; flagged via resume_with_decision.
375
376    Ok(RespondResponse {
377        approval_id: approval_id.to_string(),
378        outcome,
379        resolved_at: now,
380    })
381}
382
383/// `POST /approvals/batch/respond` -- apply decisions to multiple
384/// approvals in one call.
385pub fn handle_batch_respond(
386    admin: &ApprovalAdmin,
387    body: BatchRespondRequest,
388    now: u64,
389) -> Result<BatchRespondResponse, ApprovalHandlerError> {
390    if body.decisions.is_empty() {
391        return Err(ApprovalHandlerError::BadRequest(
392            "batch respond requires at least one decision".into(),
393        ));
394    }
395
396    let mut results = Vec::with_capacity(body.decisions.len());
397    let mut approved = 0usize;
398    let mut denied = 0usize;
399    let mut rejected = 0usize;
400
401    for entry in body.decisions {
402        let approval_id = entry.approval_id.clone();
403        if entry.token.request_id != approval_id {
404            rejected += 1;
405            results.push(BatchRespondResult {
406                approval_id,
407                status: "rejected".into(),
408                outcome: None,
409                error: Some(format!(
410                    "token request_id {} mismatches approval_id",
411                    entry.token.request_id
412                )),
413            });
414            continue;
415        }
416
417        let decision = ApprovalDecision {
418            approval_id: approval_id.clone(),
419            outcome: entry.outcome.clone(),
420            reason: entry.reason,
421            approver: entry.approver,
422            token: entry.token,
423            received_at: now,
424        };
425
426        match resume_with_decision(admin.store.as_ref(), &decision, now) {
427            Ok(outcome) => {
428                match outcome {
429                    ApprovalOutcome::Approved => approved += 1,
430                    ApprovalOutcome::Denied => denied += 1,
431                }
432                results.push(BatchRespondResult {
433                    approval_id,
434                    status: "resolved".into(),
435                    outcome: Some(outcome),
436                    error: None,
437                });
438            }
439            Err(e) => {
440                rejected += 1;
441                let handler_err: ApprovalHandlerError = e.into();
442                results.push(BatchRespondResult {
443                    approval_id,
444                    status: "rejected".into(),
445                    outcome: None,
446                    error: Some(handler_err.message()),
447                });
448            }
449        }
450    }
451
452    let total = results.len();
453    Ok(BatchRespondResponse {
454        results,
455        summary: BatchRespondSummary {
456            total,
457            approved,
458            denied,
459            rejected,
460        },
461    })
462}
463
464pub fn handle_create_threshold_proposal(
465    admin: &ApprovalAdmin,
466    body: CreateThresholdProposalRequest,
467    now: u64,
468) -> Result<ThresholdApprovalCollectorProposal, ApprovalHandlerError> {
469    admin
470        .threshold_collector()?
471        .create_proposal(
472            body.proposal,
473            body.requirement,
474            body.submitter,
475            body.require_submitter_separation,
476            now,
477        )
478        .map_err(Into::into)
479}
480
481pub fn handle_get_threshold_proposal(
482    admin: &ApprovalAdmin,
483    proposal_id: &str,
484) -> Result<ThresholdApprovalCollectorProposal, ApprovalHandlerError> {
485    admin
486        .threshold_collector()?
487        .get_proposal(proposal_id)?
488        .ok_or_else(|| ApprovalHandlerError::NotFound(proposal_id.to_string()))
489}
490
491pub fn handle_submit_threshold_approval(
492    admin: &ApprovalAdmin,
493    proposal_id: &str,
494    body: SubmitThresholdApprovalRequest,
495    now: u64,
496) -> Result<ThresholdApprovalCollectorProposal, ApprovalHandlerError> {
497    admin
498        .threshold_collector()?
499        .submit_token(proposal_id, body.token, now)
500        .map_err(Into::into)
501}
502
503pub fn handle_deliver_threshold_approval(
504    admin: &ApprovalAdmin,
505    proposal_id: &str,
506    now: u64,
507) -> Result<CollectedThresholdApprovalSet, ApprovalHandlerError> {
508    admin
509        .threshold_collector()?
510        .deliver(proposal_id, now)
511        .map_err(Into::into)
512}