noosphere_core/api/v0alpha1/
data.rs

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