Skip to main content

heddle_thread_api/
creation.rs

1//! Prepare creation once and retain the original signed identity for retries
2//! and later replication. Preparation performs no network or repository I/O.
3use api::v2::client::{ClientError, RpcTransport};
4use crypto::Signer;
5use heddle_object_model::object::{OperationId, thread_replication::ThreadGenesis};
6
7use crate::{Remote, contract::*, replication::opening, rpc, transport::Error};
8
9pub struct ThreadCreation {
10    request: StartThreadRequest,
11    reference: ThreadRef,
12}
13
14impl ThreadCreation {
15    pub fn sign(
16        operation_id: impl Into<String>,
17        genesis: &ThreadGenesis,
18        signer: &impl Signer,
19    ) -> Result<Self, Error> {
20        let operation_id = operation_id.into();
21        validate_operation(&operation_id)?;
22        Self::from_signed(operation_id, opening::sign_genesis(genesis, signer)?)
23    }
24
25    pub fn sign_with_authority(
26        operation_id: impl Into<String>,
27        genesis: &ThreadGenesis,
28        signer: &impl Signer,
29        creator_authority: Vec<u8>,
30    ) -> Result<Self, Error> {
31        Self::from_signed_with_authority(
32            operation_id,
33            opening::sign_genesis(genesis, signer)?,
34            creator_authority,
35        )
36    }
37
38    /// Relay an existing creator record without replacing its author or key.
39    pub fn from_signed(
40        operation_id: impl Into<String>,
41        record: SignedRecord,
42    ) -> Result<Self, Error> {
43        Self::from_signed_with_authority(operation_id, record, Vec::new())
44    }
45
46    /// Preserve the exact original proof when publishing account-owned work.
47    /// Receiver-side independent authority admission is still mandatory.
48    pub fn from_signed_with_authority(
49        operation_id: impl Into<String>,
50        record: SignedRecord,
51        creator_authority: Vec<u8>,
52    ) -> Result<Self, Error> {
53        let operation_id = operation_id.into();
54        validate_operation(&operation_id)?;
55        let genesis = ThreadGenesis::decode(&record.canonical_record)
56            .map_err(|_| Error::Protocol("invalid canonical Thread genesis"))?;
57        use heddle_object_model::object::thread_replication::GenesisOwner;
58        match &genesis.owner {
59            GenesisOwner::LocalKey(_) if !creator_authority.is_empty() => {
60                return Err(Error::Protocol(
61                    "local-key ownership requires an explicit claim, not an account proof on upload",
62                ));
63            }
64            GenesisOwner::Account(_) if creator_authority.is_empty() => {
65                return Err(Error::Protocol(
66                    "account-owned genesis requires original creator authority",
67                ));
68            }
69            _ => {}
70        }
71        if creator_authority.len() > 64 * 1024 {
72            return Err(Error::Protocol("creator authority exceeds bound"));
73        }
74        let reference = ThreadRef {
75            spool: Some(SpoolRef {
76                id: genesis.spool.clone(),
77            }),
78            id: Some(ThreadId {
79                value: genesis
80                    .id()
81                    .map_err(|_| Error::Protocol("invalid Thread identity"))?
82                    .as_bytes()
83                    .to_vec(),
84            }),
85        };
86        opening::verify_genesis(&record, &reference)?;
87        Ok(Self {
88            request: StartThreadRequest {
89                client_operation_id: operation_id,
90                spool: reference.spool.clone(),
91                thread_genesis: Some(record),
92                creator_authority,
93            },
94            reference,
95        })
96    }
97
98    pub fn request(&self) -> &StartThreadRequest {
99        &self.request
100    }
101    pub fn reference(&self) -> &ThreadRef {
102        &self.reference
103    }
104    pub fn genesis_record(&self) -> ThreadGenesisRecord {
105        ThreadGenesisRecord {
106            boundary_acceptances: Vec::new(),
107            ownership_claims: vec![],
108            ownership_claim_admissions: vec![],
109            ownership_resolutions: vec![],
110            ownership_resolution_admissions: vec![],
111            genesis: self.request.thread_genesis.clone(),
112            creator_authority: self.request.creator_authority.clone(),
113            admission: None,
114        }
115    }
116}
117
118fn validate_operation(operation_id: &str) -> Result<(), Error> {
119    operation_id
120        .parse::<OperationId>()
121        .map(|_| ())
122        .map_err(|_| Error::Protocol("client operation ID must be a UUID"))
123}
124
125impl<T: RpcTransport<Error = Error>> Remote<T> {
126    /// One command returns its receipt and resulting overview. Bind subsequent
127    /// calls with `remote.thread(creation.reference().clone())`, without lookup.
128    pub async fn start_thread(
129        &self,
130        creation: &ThreadCreation,
131    ) -> Result<ThreadMutationResponse, ClientError<Error>> {
132        if !self
133            .description
134            .understood_signed_record_formats
135            .iter()
136            .any(|format| format == heddle_object_model::object::thread_replication::GENESIS_FORMAT)
137        {
138            return Err(ClientError::Transport(Error::Protocol(
139                "endpoint does not understand Thread genesis",
140            )));
141        }
142        let response = self
143            .api
144            .call::<rpc::ThreadServiceStartThread>(creation.request())
145            .await?;
146        let receipt = response
147            .receipt
148            .as_ref()
149            .ok_or(ClientError::Transport(Error::Protocol(
150                "Thread creation response has no receipt",
151            )))?;
152        if receipt.client_operation_id != creation.request.client_operation_id
153            || receipt.endpoint != self.description.endpoint
154            || receipt.outcome.is_none()
155            || response
156                .thread
157                .as_ref()
158                .is_some_and(|thread| thread.r#ref.as_ref() != Some(creation.reference()))
159            || (matches!(receipt.outcome, Some(mutation_receipt::Outcome::Applied(_)))
160                && response.thread.is_none())
161        {
162            return Err(ClientError::Transport(Error::Protocol(
163                "Thread creation response does not match the command",
164            )));
165        }
166        Ok(response)
167    }
168}