noosphere_api/
data.rs

1use std::{fmt::Display, str::FromStr};
2
3use anyhow::{anyhow, Result};
4use cid::Cid;
5use noosphere_core::{
6    authority::{generate_capability, SphereAbility, SPHERE_SEMANTICS},
7    data::{Bundle, Did, Jwt, Link, MemoIpld},
8    error::NoosphereError,
9};
10use noosphere_storage::{base64_decode, base64_encode};
11use reqwest::StatusCode;
12use serde::{Deserialize, Deserializer, Serialize};
13use thiserror::Error;
14use ucan::{
15    chain::ProofChain,
16    crypto::{did::DidParser, KeyMaterial},
17    store::UcanStore,
18    Ucan,
19};
20
21pub trait AsQuery {
22    fn as_query(&self) -> Result<Option<String>>;
23}
24
25impl AsQuery for () {
26    fn as_query(&self) -> Result<Option<String>> {
27        Ok(None)
28    }
29}
30
31// NOTE: Adapted from https://github.com/tokio-rs/axum/blob/7caa4a3a47a31c211d301f3afbc518ea2c07b4de/examples/query-params-with-empty-strings/src/main.rs#L42-L54
32/// Serde deserialization decorator to map empty Strings to None,
33fn empty_string_as_none<'de, D, T>(de: D) -> Result<Option<T>, D::Error>
34where
35    D: Deserializer<'de>,
36    T: FromStr,
37    T::Err: std::fmt::Display,
38{
39    let opt = Option::<String>::deserialize(de)?;
40    match opt.as_deref() {
41        None | Some("") => Ok(None),
42        Some(s) => FromStr::from_str(s)
43            .map_err(serde::de::Error::custom)
44            .map(Some),
45    }
46}
47
48/// The query parameters expected for the "replicate" API route
49#[derive(Debug, Serialize, Deserialize)]
50pub struct ReplicateParameters {
51    /// This is the last revision of the content that is being fetched that is
52    /// already fully available to the caller of the API
53    #[serde(default, deserialize_with = "empty_string_as_none")]
54    pub since: Option<Link<MemoIpld>>,
55}
56
57impl AsQuery for ReplicateParameters {
58    fn as_query(&self) -> Result<Option<String>> {
59        Ok(self.since.as_ref().map(|since| format!("since={since}")))
60    }
61}
62
63/// The query parameters expected for the "fetch" API route
64#[derive(Debug, Serialize, Deserialize)]
65pub struct FetchParameters {
66    /// This is the last revision of the "counterpart" sphere that is managed
67    /// by the API host that the client is fetching from
68    #[serde(default, deserialize_with = "empty_string_as_none")]
69    pub since: Option<Link<MemoIpld>>,
70}
71
72impl AsQuery for FetchParameters {
73    fn as_query(&self) -> Result<Option<String>> {
74        Ok(self.since.as_ref().map(|since| format!("since={since}")))
75    }
76}
77
78/// The possible responses from the "fetch" API route
79#[derive(Debug, Serialize, Deserialize)]
80pub enum FetchResponse {
81    /// There are new revisions to the local and "counterpart" spheres to sync
82    /// with local history
83    NewChanges {
84        /// The tip of the "counterpart" sphere that is managed by the API host
85        /// that the client is fetching from
86        tip: Cid,
87    },
88    /// There are no new revisions since the revision specified in the initial
89    /// fetch request
90    UpToDate,
91}
92
93/// The body payload expected by the "push" API route
94#[derive(Debug, Serialize, Deserialize)]
95pub struct PushBody {
96    /// The DID of the local sphere whose revisions are being pushed
97    pub sphere: Did,
98    /// The base revision represented by the payload being pushed; if the
99    /// entire history is being pushed, then this should be None
100    pub local_base: Option<Link<MemoIpld>>,
101    /// The tip of the history represented by the payload being pushed
102    pub local_tip: Link<MemoIpld>,
103    /// The last received tip of the counterpart sphere
104    pub counterpart_tip: Option<Link<MemoIpld>>,
105    /// A bundle of all the blocks needed to hydrate the revisions from the
106    /// base to the tip of history as represented by this payload
107    pub blocks: Bundle,
108    /// An optional name record to publish to the Noosphere Name System
109    pub name_record: Option<Jwt>,
110}
111
112/// The possible responses from the "push" API route
113#[derive(Debug, PartialEq, Eq, Serialize, Deserialize)]
114pub enum PushResponse {
115    /// The new history was accepted
116    Accepted {
117        /// This is the new tip of the "counterpart" sphere after accepting
118        /// the latest history from the local sphere. This is guaranteed to be
119        /// at least one revision ahead of the latest revision being tracked
120        /// by the client (because it points to the newly received tip of the
121        /// local sphere's history)
122        new_tip: Link<MemoIpld>,
123        /// The blocks needed to hydrate the revisions of the "counterpart"
124        /// sphere history to the tip represented in this response
125        blocks: Bundle,
126    },
127    /// The history was already known by the API host, so no changes were made
128    NoChange,
129}
130
131#[derive(Error, Debug)]
132pub enum PushError {
133    #[error("Pushed history conflicts with canonical history")]
134    Conflict,
135    #[error("Missing some implied history")]
136    MissingHistory,
137    #[error("Replica is up to date")]
138    UpToDate,
139    #[error("Internal error")]
140    Internal(anyhow::Error),
141}
142
143impl From<NoosphereError> for PushError {
144    fn from(error: NoosphereError) -> Self {
145        error.into()
146    }
147}
148
149impl From<anyhow::Error> for PushError {
150    fn from(value: anyhow::Error) -> Self {
151        PushError::Internal(value)
152    }
153}
154
155impl From<PushError> for StatusCode {
156    fn from(error: PushError) -> Self {
157        match error {
158            PushError::Conflict => StatusCode::CONFLICT,
159            PushError::MissingHistory => StatusCode::UNPROCESSABLE_ENTITY,
160            PushError::UpToDate => StatusCode::BAD_REQUEST,
161            PushError::Internal(error) => {
162                error!("Internal: {:?}", error);
163                StatusCode::INTERNAL_SERVER_ERROR
164            }
165        }
166    }
167}
168
169/// The response from the "identify" API route; this is a signed response that
170/// allows the client to verify the authority of the API host
171#[derive(Debug, Clone, Serialize, Deserialize)]
172pub struct IdentifyResponse {
173    /// The DID of the API host
174    pub gateway_identity: Did,
175    /// The DID of the "counterpart" sphere
176    pub sphere_identity: Did,
177    /// The signature of the API host over this payload, as base64-encoded bytes
178    pub signature: String,
179    /// The proof that the API host was authorized by the "counterpart" sphere
180    /// in the form of a UCAN JWT
181    pub proof: String,
182}
183
184impl IdentifyResponse {
185    pub async fn sign<K>(sphere_identity: &str, key: &K, proof: &Ucan) -> Result<Self>
186    where
187        K: KeyMaterial,
188    {
189        let gateway_identity = Did(key.get_did().await?);
190        let signature = base64_encode(
191            &key.sign(&[gateway_identity.as_bytes(), sphere_identity.as_bytes()].concat())
192                .await?,
193        )?;
194        Ok(IdentifyResponse {
195            gateway_identity,
196            sphere_identity: sphere_identity.into(),
197            signature,
198            proof: proof.encode()?,
199        })
200    }
201
202    pub fn shares_identity_with(&self, other: &IdentifyResponse) -> bool {
203        self.gateway_identity == other.gateway_identity
204            && self.sphere_identity == other.sphere_identity
205    }
206
207    /// Verifies that the signature scheme on the payload. The signature is made
208    /// by signing the bytes of the gateway's key DID plus the bytes of the
209    /// sphere DID that the gateway claims to manage. Remember: this sphere is
210    /// not the user's sphere, but rather the "counterpart" sphere created and
211    /// modified by the gateway. Additionally, a proof is given that the gateway
212    /// has been authorized to modify its own sphere.
213    ///
214    /// This verification is intended to check two things:
215    ///
216    ///  1. The gateway has control of the key that it represents with its DID
217    ///  2. The gateway is authorized to modify the sphere it claims to manage
218    pub async fn verify<S: UcanStore>(&self, did_parser: &mut DidParser, store: &S) -> Result<()> {
219        let gateway_key = did_parser.parse(&self.gateway_identity)?;
220        let payload_bytes = [
221            self.gateway_identity.as_bytes(),
222            self.sphere_identity.as_bytes(),
223        ]
224        .concat();
225        let signature_bytes = base64_decode(&self.signature)?;
226
227        // Verify that the signature is valid
228        gateway_key.verify(&payload_bytes, &signature_bytes).await?;
229
230        let proof = ProofChain::try_from_token_string(&self.proof, None, did_parser, store).await?;
231
232        if proof.ucan().audience() != self.gateway_identity.as_str() {
233            return Err(anyhow!("Wrong audience!"));
234        }
235
236        let capability = generate_capability(&self.sphere_identity, SphereAbility::Push);
237        let capability_infos = proof.reduce_capabilities(&SPHERE_SEMANTICS);
238
239        for capability_info in capability_infos {
240            if capability_info.capability.enables(&capability)
241                && capability_info
242                    .originators
243                    .contains(self.sphere_identity.as_str())
244            {
245                return Ok(());
246            }
247        }
248
249        Err(anyhow!("Not authorized!"))
250    }
251}
252
253impl Display for IdentifyResponse {
254    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
255        write!(
256            f,
257            "((Gateway {}), (Sphere {}))",
258            self.gateway_identity, self.sphere_identity
259        )
260    }
261}