create-sia-app 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (112) hide show
  1. package/dist/index.d.ts +1 -0
  2. package/dist/index.js +179 -0
  3. package/package.json +29 -0
  4. package/template/CLAUDE.md +160 -0
  5. package/template/README.md +102 -0
  6. package/template/_gitignore +5 -0
  7. package/template/biome.json +40 -0
  8. package/template/index.html +13 -0
  9. package/template/package.json +30 -0
  10. package/template/rust/README.md +16 -0
  11. package/template/rust/sia-sdk-rs/.changeset/added_cancel_function_to_cancel_inflight_packed_uploads.md +6 -0
  12. package/template/rust/sia-sdk-rs/.changeset/check_if_we_have_enough_hosts_prior_to_encoding_in_upload_slabs.md +16 -0
  13. package/template/rust/sia-sdk-rs/.changeset/fix_slab_length_in_packed_object.md +5 -0
  14. package/template/rust/sia-sdk-rs/.changeset/fix_upload_racing_race_conditon.md +13 -0
  15. package/template/rust/sia-sdk-rs/.changeset/improved_parallelism_of_packed_uploads.md +5 -0
  16. package/template/rust/sia-sdk-rs/.changeset/progress_callback_will_now_be_called_as_expected_for_packed_uploads.md +5 -0
  17. package/template/rust/sia-sdk-rs/.github/dependabot.yml +10 -0
  18. package/template/rust/sia-sdk-rs/.github/workflows/main.yml +36 -0
  19. package/template/rust/sia-sdk-rs/.github/workflows/prepare-release.yml +34 -0
  20. package/template/rust/sia-sdk-rs/.github/workflows/release.yml +30 -0
  21. package/template/rust/sia-sdk-rs/.rustfmt.toml +4 -0
  22. package/template/rust/sia-sdk-rs/Cargo.lock +4127 -0
  23. package/template/rust/sia-sdk-rs/Cargo.toml +3 -0
  24. package/template/rust/sia-sdk-rs/LICENSE +21 -0
  25. package/template/rust/sia-sdk-rs/README.md +30 -0
  26. package/template/rust/sia-sdk-rs/indexd/CHANGELOG.md +79 -0
  27. package/template/rust/sia-sdk-rs/indexd/Cargo.toml +79 -0
  28. package/template/rust/sia-sdk-rs/indexd/benches/upload.rs +258 -0
  29. package/template/rust/sia-sdk-rs/indexd/src/app_client.rs +1710 -0
  30. package/template/rust/sia-sdk-rs/indexd/src/builder.rs +354 -0
  31. package/template/rust/sia-sdk-rs/indexd/src/download.rs +379 -0
  32. package/template/rust/sia-sdk-rs/indexd/src/hosts.rs +659 -0
  33. package/template/rust/sia-sdk-rs/indexd/src/lib.rs +827 -0
  34. package/template/rust/sia-sdk-rs/indexd/src/mock.rs +162 -0
  35. package/template/rust/sia-sdk-rs/indexd/src/object_encryption.rs +125 -0
  36. package/template/rust/sia-sdk-rs/indexd/src/quic.rs +575 -0
  37. package/template/rust/sia-sdk-rs/indexd/src/rhp4.rs +52 -0
  38. package/template/rust/sia-sdk-rs/indexd/src/slabs.rs +497 -0
  39. package/template/rust/sia-sdk-rs/indexd/src/upload.rs +629 -0
  40. package/template/rust/sia-sdk-rs/indexd/src/wasm_time.rs +41 -0
  41. package/template/rust/sia-sdk-rs/indexd/src/web_transport.rs +398 -0
  42. package/template/rust/sia-sdk-rs/indexd_ffi/CHANGELOG.md +76 -0
  43. package/template/rust/sia-sdk-rs/indexd_ffi/Cargo.toml +47 -0
  44. package/template/rust/sia-sdk-rs/indexd_ffi/examples/python/README.md +10 -0
  45. package/template/rust/sia-sdk-rs/indexd_ffi/examples/python/example.py +130 -0
  46. package/template/rust/sia-sdk-rs/indexd_ffi/src/bin/uniffi-bindgen.rs +3 -0
  47. package/template/rust/sia-sdk-rs/indexd_ffi/src/builder.rs +377 -0
  48. package/template/rust/sia-sdk-rs/indexd_ffi/src/io.rs +155 -0
  49. package/template/rust/sia-sdk-rs/indexd_ffi/src/lib.rs +1039 -0
  50. package/template/rust/sia-sdk-rs/indexd_ffi/src/logging.rs +58 -0
  51. package/template/rust/sia-sdk-rs/indexd_ffi/src/tls.rs +23 -0
  52. package/template/rust/sia-sdk-rs/indexd_wasm/Cargo.toml +33 -0
  53. package/template/rust/sia-sdk-rs/indexd_wasm/src/lib.rs +818 -0
  54. package/template/rust/sia-sdk-rs/knope.toml +54 -0
  55. package/template/rust/sia-sdk-rs/sia_derive/CHANGELOG.md +38 -0
  56. package/template/rust/sia-sdk-rs/sia_derive/Cargo.toml +19 -0
  57. package/template/rust/sia-sdk-rs/sia_derive/src/lib.rs +278 -0
  58. package/template/rust/sia-sdk-rs/sia_sdk/CHANGELOG.md +91 -0
  59. package/template/rust/sia-sdk-rs/sia_sdk/Cargo.toml +59 -0
  60. package/template/rust/sia-sdk-rs/sia_sdk/benches/merkle_root.rs +12 -0
  61. package/template/rust/sia-sdk-rs/sia_sdk/src/blake2.rs +22 -0
  62. package/template/rust/sia-sdk-rs/sia_sdk/src/consensus.rs +767 -0
  63. package/template/rust/sia-sdk-rs/sia_sdk/src/encoding/v1.rs +257 -0
  64. package/template/rust/sia-sdk-rs/sia_sdk/src/encoding/v2.rs +291 -0
  65. package/template/rust/sia-sdk-rs/sia_sdk/src/encoding.rs +26 -0
  66. package/template/rust/sia-sdk-rs/sia_sdk/src/encoding_async/v2.rs +367 -0
  67. package/template/rust/sia-sdk-rs/sia_sdk/src/encoding_async.rs +6 -0
  68. package/template/rust/sia-sdk-rs/sia_sdk/src/encryption.rs +303 -0
  69. package/template/rust/sia-sdk-rs/sia_sdk/src/erasure_coding.rs +347 -0
  70. package/template/rust/sia-sdk-rs/sia_sdk/src/lib.rs +15 -0
  71. package/template/rust/sia-sdk-rs/sia_sdk/src/macros.rs +435 -0
  72. package/template/rust/sia-sdk-rs/sia_sdk/src/merkle.rs +112 -0
  73. package/template/rust/sia-sdk-rs/sia_sdk/src/rhp/merkle.rs +357 -0
  74. package/template/rust/sia-sdk-rs/sia_sdk/src/rhp/rpc.rs +1507 -0
  75. package/template/rust/sia-sdk-rs/sia_sdk/src/rhp/types.rs +146 -0
  76. package/template/rust/sia-sdk-rs/sia_sdk/src/rhp.rs +7 -0
  77. package/template/rust/sia-sdk-rs/sia_sdk/src/seed.rs +278 -0
  78. package/template/rust/sia-sdk-rs/sia_sdk/src/signing.rs +236 -0
  79. package/template/rust/sia-sdk-rs/sia_sdk/src/types/common.rs +677 -0
  80. package/template/rust/sia-sdk-rs/sia_sdk/src/types/currency.rs +450 -0
  81. package/template/rust/sia-sdk-rs/sia_sdk/src/types/specifier.rs +110 -0
  82. package/template/rust/sia-sdk-rs/sia_sdk/src/types/spendpolicy.rs +778 -0
  83. package/template/rust/sia-sdk-rs/sia_sdk/src/types/utils.rs +117 -0
  84. package/template/rust/sia-sdk-rs/sia_sdk/src/types/v1.rs +1737 -0
  85. package/template/rust/sia-sdk-rs/sia_sdk/src/types/v2.rs +1726 -0
  86. package/template/rust/sia-sdk-rs/sia_sdk/src/types/work.rs +59 -0
  87. package/template/rust/sia-sdk-rs/sia_sdk/src/types.rs +16 -0
  88. package/template/scripts/setup-rust.js +29 -0
  89. package/template/src/App.tsx +13 -0
  90. package/template/src/components/DevNote.tsx +21 -0
  91. package/template/src/components/auth/ApproveScreen.tsx +84 -0
  92. package/template/src/components/auth/AuthFlow.tsx +77 -0
  93. package/template/src/components/auth/ConnectScreen.tsx +214 -0
  94. package/template/src/components/auth/LoadingScreen.tsx +8 -0
  95. package/template/src/components/auth/RecoveryScreen.tsx +182 -0
  96. package/template/src/components/upload/UploadZone.tsx +314 -0
  97. package/template/src/index.css +9 -0
  98. package/template/src/lib/constants.ts +8 -0
  99. package/template/src/lib/format.ts +35 -0
  100. package/template/src/lib/hex.ts +13 -0
  101. package/template/src/lib/sdk.ts +25 -0
  102. package/template/src/lib/wasm-env.ts +5 -0
  103. package/template/src/main.tsx +12 -0
  104. package/template/src/stores/auth.ts +86 -0
  105. package/template/tsconfig.app.json +31 -0
  106. package/template/tsconfig.json +7 -0
  107. package/template/tsconfig.node.json +26 -0
  108. package/template/vite.config.ts +18 -0
  109. package/template/wasm/indexd_wasm/indexd_wasm.d.ts +309 -0
  110. package/template/wasm/indexd_wasm/indexd_wasm.js +1507 -0
  111. package/template/wasm/indexd_wasm/indexd_wasm_bg.wasm +0 -0
  112. package/template/wasm/indexd_wasm/package.json +31 -0
@@ -0,0 +1,1710 @@
1
+ use base64::engine::general_purpose::URL_SAFE;
2
+ use base64::prelude::*;
3
+ use std::time::Duration;
4
+
5
+ use async_trait::async_trait;
6
+ use blake2::Digest;
7
+ use chrono::{DateTime, Utc};
8
+ use reqwest::{Method, StatusCode};
9
+ use serde_json::to_vec;
10
+ use serde_with::base64::Base64;
11
+ use serde_with::serde_as;
12
+ use sia::blake2::Blake2b256;
13
+ use sia::encryption::EncryptionKey;
14
+ use sia::rhp::Host;
15
+
16
+ use thiserror::Error;
17
+
18
+ use serde::de::DeserializeOwned;
19
+ use serde::ser::Serializer;
20
+ use serde::{Deserialize, Serialize};
21
+
22
+ use crate::object_encryption::DecryptError;
23
+ use crate::slabs::Sector;
24
+ use crate::{Object, PinnedSlab, SealedObject, Slab};
25
+ use sia::signing::{PrivateKey, PublicKey};
26
+ use sia::types::Hash256;
27
+ use sia::types::v2::Protocol;
28
+
29
+ pub use reqwest::{IntoUrl, Url};
30
+
31
+ const QUERY_PARAM_VALID_UNTIL: &str = "sv";
32
+ const QUERY_PARAM_CREDENTIAL: &str = "sc";
33
+ const QUERY_PARAM_SIGNATURE: &str = "ss";
34
+
35
+ #[derive(Debug, Error)]
36
+ pub enum Error {
37
+ #[error("indexd responded with an error: {0}")]
38
+ Api(String),
39
+
40
+ #[error("invalid header value: {0}")]
41
+ InvalidHeader(#[from] reqwest::header::InvalidHeaderValue),
42
+
43
+ #[error("http error: {0}")]
44
+ Reqwest(#[from] reqwest::Error),
45
+
46
+ #[error("serde error: {0}")]
47
+ Serde(#[from] serde_json::Error),
48
+
49
+ #[error("url parse error: {0}")]
50
+ UrlParse(#[from] url::ParseError),
51
+
52
+ #[error("user rejected connection request")]
53
+ UserRejected,
54
+
55
+ #[error("format error: {0}")]
56
+ Format(String),
57
+
58
+ #[error("decryption error: {0}")]
59
+ Decryption(#[from] DecryptError),
60
+
61
+ #[error("custom error: {0}")]
62
+ Custom(String),
63
+ }
64
+
65
+ #[derive(Debug, Deserialize, Serialize, PartialEq)]
66
+ #[serde(rename_all = "camelCase")]
67
+ pub struct AuthConnectStatusResponse {
68
+ approved: bool,
69
+ user_secret: Option<Hash256>,
70
+ }
71
+
72
+ #[derive(Debug, Deserialize, Serialize, PartialEq)]
73
+ #[serde(rename_all = "camelCase")]
74
+ pub struct RegisterAppRequest {
75
+ #[serde(rename = "appID")]
76
+ pub app_id: Hash256,
77
+ pub name: String,
78
+ pub description: String,
79
+ #[serde(rename = "serviceURL")]
80
+ pub service_url: Url,
81
+ #[serde(rename = "logoURL")]
82
+ pub logo_url: Option<Url>,
83
+ #[serde(rename = "callbackURL")]
84
+ pub callback_url: Option<Url>,
85
+ }
86
+
87
+ #[derive(Debug, Deserialize, Serialize, PartialEq)]
88
+ #[serde(rename_all = "camelCase")]
89
+ pub struct RegisterAppResponse {
90
+ #[serde(rename = "responseURL")]
91
+ pub response_url: String,
92
+ #[serde(rename = "statusURL")]
93
+ pub status_url: String,
94
+ #[serde(rename = "registerURL")]
95
+ pub register_url: String,
96
+ pub expiration: DateTime<Utc>,
97
+ }
98
+
99
+ #[derive(Debug, Clone, Serialize, PartialEq)]
100
+ #[serde(rename_all = "camelCase")]
101
+ pub struct SlabPinParams {
102
+ pub encryption_key: EncryptionKey,
103
+ pub min_shards: u8,
104
+ pub sectors: Vec<Sector>,
105
+ }
106
+
107
+ pub struct ObjectsCursor {
108
+ pub after: DateTime<Utc>,
109
+ pub id: Hash256,
110
+ }
111
+
112
+ /// An SealedObjectEvent represents an object and whether it was deleted or not.
113
+ #[serde_as]
114
+ #[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
115
+ #[serde(rename_all = "camelCase")]
116
+ pub struct SealedObjectEvent {
117
+ #[serde(rename = "key")]
118
+ pub id: Hash256,
119
+ pub deleted: bool,
120
+ pub updated_at: DateTime<Utc>,
121
+ pub object: Option<SealedObject>,
122
+ }
123
+
124
+ #[derive(Debug, Clone, Copy, PartialEq, Deserialize)]
125
+ #[serde(rename_all = "camelCase")]
126
+ pub struct GeoLocation {
127
+ pub latitude: f64,
128
+ pub longitude: f64,
129
+ }
130
+
131
+ impl Serialize for GeoLocation {
132
+ fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
133
+ where
134
+ S: Serializer,
135
+ {
136
+ let formatted = format!("({:.6},{:.6})", self.latitude, self.longitude);
137
+ serializer.serialize_str(&formatted)
138
+ }
139
+ }
140
+
141
+ #[derive(Debug, Clone, Copy, PartialEq, Deserialize)]
142
+ #[serde(rename_all = "camelCase")]
143
+ pub enum HostSort {
144
+ Distance(GeoLocation),
145
+ }
146
+
147
+ #[derive(Debug, Clone, Default, PartialEq, Serialize)]
148
+ pub struct HostQuery {
149
+ #[serde(skip_serializing_if = "Option::is_none")]
150
+ pub location: Option<GeoLocation>,
151
+ #[serde(skip_serializing_if = "Option::is_none")]
152
+ pub offset: Option<u64>,
153
+ #[serde(skip_serializing_if = "Option::is_none")]
154
+ pub limit: Option<u64>,
155
+ #[serde(skip_serializing_if = "Option::is_none")]
156
+ pub protocol: Option<Protocol>,
157
+ #[serde(skip_serializing_if = "Option::is_none")]
158
+ pub country: Option<String>,
159
+ }
160
+
161
+ #[derive(Debug, Deserialize, PartialEq)]
162
+ #[serde(rename_all = "camelCase")]
163
+ pub struct App {
164
+ pub id: Hash256,
165
+ pub description: String,
166
+ pub logo_url: Option<String>,
167
+ pub service_url: Option<String>,
168
+ }
169
+
170
+ #[derive(Debug, Deserialize, PartialEq)]
171
+ #[serde(rename_all = "camelCase")]
172
+ pub struct Account {
173
+ pub account_key: PublicKey,
174
+ pub connect_key: String,
175
+ pub max_pinned_data: u64,
176
+ pub pinned_data: u64,
177
+ pub app: App,
178
+ pub last_used: DateTime<Utc>,
179
+ }
180
+
181
+ #[serde_as]
182
+ #[derive(Debug, Clone, PartialEq, Deserialize)]
183
+ #[serde(rename_all = "camelCase")]
184
+ struct SharedObjectResponse {
185
+ pub slabs: Vec<Slab>,
186
+ #[serde_as(as = "Option<Base64>")]
187
+ pub encrypted_metadata: Option<Vec<u8>>,
188
+ }
189
+
190
+ #[derive(Clone)]
191
+ pub struct Client {
192
+ client: reqwest::Client,
193
+ url: Url,
194
+ }
195
+
196
+ impl Client {
197
+ pub fn new<U: IntoUrl>(base_url: U) -> Result<Self, Error> {
198
+ Ok(Self {
199
+ client: reqwest::Client::new(),
200
+ url: base_url.into_url()?,
201
+ })
202
+ }
203
+
204
+ /// Helper to send a signed DELETE request.
205
+ async fn delete(&self, path: &str, app_key: &PrivateKey) -> Result<(), Error> {
206
+ let url = self.url.join(path)?;
207
+ let query_params = self.sign(
208
+ app_key,
209
+ &url,
210
+ Method::DELETE,
211
+ None,
212
+ Utc::now() + Duration::from_secs(60),
213
+ );
214
+ Self::handle_response::<EmptyResponse>(
215
+ self.client
216
+ .delete(url)
217
+ .timeout(Duration::from_secs(15))
218
+ .query(&query_params)
219
+ .send()
220
+ .await?,
221
+ )
222
+ .await
223
+ .map(|_| ())
224
+ }
225
+
226
+ /// Helper to send a signed GET request and parse the JSON
227
+ /// response.
228
+ async fn get_json<D: DeserializeOwned, Q: Serialize + ?Sized>(
229
+ &self,
230
+ path: &str,
231
+ app_key: Option<&PrivateKey>,
232
+ query_params: Option<&Q>,
233
+ ) -> Result<D, Error> {
234
+ let url = self.url.join(path)?;
235
+
236
+ let mut signing_params = None;
237
+ if let Some(app_key) = app_key {
238
+ let params = self.sign(
239
+ app_key,
240
+ &url,
241
+ Method::GET,
242
+ None,
243
+ Utc::now() + Duration::from_secs(60),
244
+ );
245
+ signing_params = Some(params);
246
+ }
247
+
248
+ let mut builder = self.client.get(url).timeout(Duration::from_secs(15));
249
+ if let Some(q) = query_params {
250
+ builder = builder.query(q); // optional query params
251
+ }
252
+ if let Some(signing_params) = &signing_params {
253
+ builder = builder.query(&signing_params);
254
+ }
255
+ Self::handle_response(builder.send().await?).await
256
+ }
257
+
258
+ /// Helper to either parse a successfully JSON response or return the error
259
+ /// message from the API.
260
+ async fn handle_response<T: DeserializeOwned>(resp: reqwest::Response) -> Result<T, Error> {
261
+ if resp.status().is_success() {
262
+ Ok(resp.json::<T>().await?)
263
+ } else {
264
+ Err(Error::Api(resp.text().await?))
265
+ }
266
+ }
267
+
268
+ // Helper to send a signed POST request and parse the JSON
269
+ // response.
270
+ async fn post_json<S: Serialize, D: DeserializeOwned>(
271
+ &self,
272
+ path: &str,
273
+ app_key: Option<&PrivateKey>,
274
+ body: Option<&S>,
275
+ ) -> Result<D, Error> {
276
+ let body = body.and_then(|body| to_vec(body).ok());
277
+ let url = self.url.join(path)?;
278
+
279
+ let mut query_params = None;
280
+ if let Some(app_key) = app_key {
281
+ query_params = Some(self.sign(
282
+ app_key,
283
+ &url,
284
+ Method::POST,
285
+ body.as_deref(),
286
+ Utc::now() + Duration::from_secs(60),
287
+ ));
288
+ }
289
+
290
+ let mut builder = self.client.post(url).timeout(Duration::from_secs(15));
291
+ if let Some(query_params) = &query_params {
292
+ builder = builder.query(&query_params);
293
+ }
294
+ if let Some(body) = body {
295
+ builder = builder.body(body);
296
+ }
297
+ Self::handle_response(builder.send().await?).await
298
+ }
299
+
300
+ fn request_hash(
301
+ url: &Url,
302
+ method: Method,
303
+ body: Option<&[u8]>,
304
+ valid_until: DateTime<Utc>,
305
+ ) -> Hash256 {
306
+ let host_port = url
307
+ .port()
308
+ .map_or(url.host_str().unwrap_or("localhost").to_string(), |port| {
309
+ format!("{}:{}", url.host_str().unwrap_or("localhost"), port)
310
+ });
311
+ let mut state = Blake2b256::new();
312
+ state.update(method.as_str().as_bytes());
313
+ state.update(host_port.as_bytes());
314
+ state.update(url.path().as_bytes());
315
+ state.update(valid_until.timestamp().to_le_bytes());
316
+ if let Some(body) = body {
317
+ state.update(body);
318
+ }
319
+ state.finalize().into()
320
+ }
321
+
322
+ fn sign(
323
+ &self,
324
+ app_key: &PrivateKey,
325
+ url: &Url,
326
+ method: Method,
327
+ body: Option<&[u8]>,
328
+ valid_until: DateTime<Utc>,
329
+ ) -> [(&'static str, String); 3] {
330
+ let hash = Self::request_hash(url, method, body, valid_until);
331
+ let public_key = app_key.public_key();
332
+ let signature = app_key.sign(hash.as_ref());
333
+ [
334
+ (QUERY_PARAM_VALID_UNTIL, valid_until.timestamp().to_string()),
335
+ (QUERY_PARAM_CREDENTIAL, URL_SAFE.encode(public_key)),
336
+ (QUERY_PARAM_SIGNATURE, URL_SAFE.encode(signature.as_ref())),
337
+ ]
338
+ }
339
+ }
340
+ #[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
341
+ #[cfg_attr(not(target_arch = "wasm32"), async_trait)]
342
+ pub trait AppClient: Send + Sync {
343
+ async fn check_app_authenticated(&self, app_key: &PrivateKey) -> Result<bool, Error>;
344
+
345
+ async fn request_app_connection(
346
+ &self,
347
+ opts: &RegisterAppRequest,
348
+ ) -> Result<RegisterAppResponse, Error>;
349
+
350
+ async fn check_request_status(&self, status_url: Url) -> Result<Option<Hash256>, Error>;
351
+
352
+ async fn register_app(&self, app_key: &PrivateKey, register_url: Url) -> Result<(), Error>;
353
+
354
+ async fn hosts(&self, app_key: &PrivateKey, query: HostQuery) -> Result<Vec<Host>, Error>;
355
+
356
+ async fn object(&self, app_key: &PrivateKey, key: &Hash256) -> Result<SealedObject, Error>;
357
+
358
+ async fn objects(
359
+ &self,
360
+ app_key: &PrivateKey,
361
+ cursor: Option<ObjectsCursor>,
362
+ limit: Option<usize>,
363
+ ) -> Result<Vec<SealedObjectEvent>, Error>;
364
+
365
+ async fn save_object(&self, app_key: &PrivateKey, object: &SealedObject) -> Result<(), Error>;
366
+
367
+ async fn delete_object(&self, app_key: &PrivateKey, key: &Hash256) -> Result<(), Error>;
368
+
369
+ async fn slab(&self, app_key: &PrivateKey, slab_id: &Hash256) -> Result<PinnedSlab, Error>;
370
+
371
+ async fn slab_ids(
372
+ &self,
373
+ app_key: &PrivateKey,
374
+ offset: Option<u64>,
375
+ limit: Option<u64>,
376
+ ) -> Result<Vec<Hash256>, Error>;
377
+
378
+ async fn pin_slabs(
379
+ &self,
380
+ app_key: &PrivateKey,
381
+ slabs: Vec<SlabPinParams>,
382
+ ) -> Result<Vec<Hash256>, Error>;
383
+
384
+ async fn pin_slab(&self, app_key: &PrivateKey, slab: SlabPinParams) -> Result<Hash256, Error>;
385
+
386
+ async fn unpin_slab(&self, app_key: &PrivateKey, slab_id: &Hash256) -> Result<(), Error>;
387
+
388
+ async fn prune_slabs(&self, app_key: &PrivateKey) -> Result<(), Error>;
389
+
390
+ async fn account(&self, app_key: &PrivateKey) -> Result<Account, Error>;
391
+
392
+ fn shared_object_url(
393
+ &self,
394
+ app_key: &PrivateKey,
395
+ object: &Object,
396
+ valid_until: DateTime<Utc>,
397
+ ) -> Result<Url, Error>;
398
+
399
+ async fn shared_object(&self, share_url: Url) -> Result<Object, Error>;
400
+ }
401
+
402
+ /// A placeholder type that implements serde::Deserialize for endpoints that
403
+ /// return no content.
404
+ struct EmptyResponse;
405
+
406
+ impl<'de> serde::Deserialize<'de> for EmptyResponse {
407
+ fn deserialize<D>(_: D) -> Result<Self, D::Error>
408
+ where
409
+ D: serde::Deserializer<'de>,
410
+ {
411
+ Ok(EmptyResponse)
412
+ }
413
+ }
414
+
415
+ #[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
416
+ #[cfg_attr(not(target_arch = "wasm32"), async_trait)]
417
+ impl AppClient for Client {
418
+ /// Checks if the application is authenticated with the indexer. It returns
419
+ /// true if authenticated, false if not, and an error if the request fails.
420
+ async fn check_app_authenticated(&self, app_key: &PrivateKey) -> Result<bool, Error> {
421
+ let url = self.url.join("auth/check")?;
422
+ let query_params = self.sign(
423
+ app_key,
424
+ &url,
425
+ Method::GET,
426
+ None,
427
+ Utc::now() + Duration::from_secs(60),
428
+ );
429
+ let resp = self
430
+ .client
431
+ .get(url)
432
+ .timeout(Duration::from_secs(15))
433
+ .query(&query_params)
434
+ .send()
435
+ .await?;
436
+ match resp.status() {
437
+ StatusCode::UNAUTHORIZED => Ok(false),
438
+ StatusCode::NO_CONTENT => Ok(true),
439
+ _ => Err(Error::Api(resp.text().await?)),
440
+ }
441
+ }
442
+
443
+ /// Requests an application connection to the indexer.
444
+ async fn request_app_connection(
445
+ &self,
446
+ opts: &RegisterAppRequest,
447
+ ) -> Result<RegisterAppResponse, Error> {
448
+ self.post_json("auth/connect", None, Some(opts)).await
449
+ }
450
+
451
+ /// Checks if an auth request has been approved.
452
+ ///
453
+ /// If approved, it returns the user secret used
454
+ /// to derive the application key.
455
+ ///
456
+ /// If the auth request is still pending, it returns None.
457
+ async fn check_request_status(&self, status_url: Url) -> Result<Option<Hash256>, Error> {
458
+ let resp = self
459
+ .client
460
+ .get(status_url)
461
+ .timeout(Duration::from_secs(15))
462
+ .send()
463
+ .await?;
464
+ match resp.status() {
465
+ StatusCode::OK => {
466
+ if let Ok(status) = resp.json::<AuthConnectStatusResponse>().await {
467
+ if status.approved {
468
+ Ok(status.user_secret)
469
+ } else {
470
+ Ok(None)
471
+ }
472
+ } else {
473
+ Err(Error::Api("invalid response format".to_string()))
474
+ }
475
+ }
476
+ StatusCode::NOT_FOUND => Err(Error::UserRejected),
477
+ _ => Err(Error::Api(resp.text().await?)),
478
+ }
479
+ }
480
+
481
+ /// Registers the application key with the indexer.
482
+ async fn register_app(&self, app_key: &PrivateKey, register_url: Url) -> Result<(), Error> {
483
+ let query_params = self.sign(
484
+ app_key,
485
+ &register_url,
486
+ Method::POST,
487
+ None,
488
+ Utc::now() + Duration::from_secs(60),
489
+ );
490
+ let resp = self
491
+ .client
492
+ .post(register_url)
493
+ .timeout(Duration::from_secs(15))
494
+ .query(&query_params)
495
+ .send()
496
+ .await?;
497
+ match resp.status() {
498
+ StatusCode::NO_CONTENT => Ok(()),
499
+ _ => Err(Error::Api(resp.text().await?)),
500
+ }
501
+ }
502
+
503
+ /// Returns all usable hosts.
504
+ ///
505
+ /// # Arguments
506
+ /// * `query` - Parameters to control the hosts listing.
507
+ async fn hosts(&self, app_key: &PrivateKey, query: HostQuery) -> Result<Vec<Host>, Error> {
508
+ self.get_json("hosts", Some(app_key), Some(&query)).await
509
+ }
510
+
511
+ /// Retrieves an object from the indexer by its key.
512
+ async fn object(&self, app_key: &PrivateKey, key: &Hash256) -> Result<SealedObject, Error> {
513
+ self.get_json::<_, ()>(&format!("objects/{key}"), Some(app_key), None)
514
+ .await
515
+ }
516
+
517
+ /// Fetches a list of objects from the indexer. Can be paginated using the
518
+ /// cursor and limit arguments.
519
+ async fn objects(
520
+ &self,
521
+ app_key: &PrivateKey,
522
+ cursor: Option<ObjectsCursor>,
523
+ limit: Option<usize>,
524
+ ) -> Result<Vec<SealedObjectEvent>, Error> {
525
+ let mut query_params = Vec::new();
526
+ if let Some(limit) = limit {
527
+ query_params.push(("limit", limit.to_string()));
528
+ }
529
+ if let Some(ObjectsCursor { after, id }) = cursor {
530
+ query_params.push(("after", after.to_rfc3339())); // indexd expects RFC3339
531
+ query_params.push(("key", id.to_string()));
532
+ }
533
+ self.get_json::<_, _>("objects", Some(app_key), Some(&query_params))
534
+ .await
535
+ }
536
+
537
+ /// Saves an object to the indexer.
538
+ async fn save_object(&self, app_key: &PrivateKey, object: &SealedObject) -> Result<(), Error> {
539
+ self.post_json::<_, EmptyResponse>("objects", Some(app_key), Some(object))
540
+ .await
541
+ .map(|_| ())
542
+ }
543
+
544
+ /// Deletes an object from the indexer by its key.
545
+ async fn delete_object(&self, app_key: &PrivateKey, key: &Hash256) -> Result<(), Error> {
546
+ self.delete(&format!("objects/{key}"), app_key).await
547
+ }
548
+
549
+ /// Retrieves a slab from the indexer by its ID.
550
+ async fn slab(&self, app_key: &PrivateKey, slab_id: &Hash256) -> Result<PinnedSlab, Error> {
551
+ self.get_json::<_, ()>(&format!("slabs/{slab_id}"), Some(app_key), None)
552
+ .await
553
+ }
554
+
555
+ /// Fetches the digests of slabs associated with the account. It supports
556
+ /// pagination through the provided options.
557
+ async fn slab_ids(
558
+ &self,
559
+ app_key: &PrivateKey,
560
+ offset: Option<u64>,
561
+ limit: Option<u64>,
562
+ ) -> Result<Vec<Hash256>, Error> {
563
+ #[derive(Serialize)]
564
+ struct QueryParams {
565
+ offset: Option<u64>,
566
+ limit: Option<u64>,
567
+ }
568
+ let params = QueryParams { offset, limit };
569
+ self.get_json("slabs", Some(app_key), Some(&params)).await
570
+ }
571
+
572
+ /// Pins slabs to the indexer.
573
+ async fn pin_slabs(
574
+ &self,
575
+ app_key: &PrivateKey,
576
+ slabs: Vec<SlabPinParams>,
577
+ ) -> Result<Vec<Hash256>, Error> {
578
+ self.post_json("slabs", Some(app_key), Some(&slabs)).await
579
+ }
580
+
581
+ /// Pin a slab to the indexer.
582
+ async fn pin_slab(&self, app_key: &PrivateKey, slab: SlabPinParams) -> Result<Hash256, Error> {
583
+ self.pin_slabs(app_key, vec![slab])
584
+ .await?
585
+ .into_iter()
586
+ .next()
587
+ .ok_or(Error::Custom("no slab digest".to_string()))
588
+ }
589
+
590
+ /// Unpins a slab from the indexer.
591
+ async fn unpin_slab(&self, app_key: &PrivateKey, slab_id: &Hash256) -> Result<(), Error> {
592
+ self.delete(&format!("slabs/{slab_id}"), app_key).await
593
+ }
594
+
595
+ /// Unpins slabs not used by any object on the account.
596
+ async fn prune_slabs(&self, app_key: &PrivateKey) -> Result<(), Error> {
597
+ self.post_json::<(), EmptyResponse>("slabs/prune", Some(app_key), None)
598
+ .await
599
+ .map(|_| ())
600
+ }
601
+
602
+ /// Account returns the current account.
603
+ async fn account(&self, app_key: &PrivateKey) -> Result<Account, Error> {
604
+ self.get_json::<_, ()>("account", Some(app_key), None).await
605
+ }
606
+
607
+ /// Creates a signed url that can be shared with others
608
+ /// to give read access to a single object. An expired
609
+ /// link does not necessarily remove access to an object.
610
+ ///
611
+ /// # Arguments
612
+ /// - `object` the object to create the link for
613
+ /// - `valid_until` the time the link expires
614
+ fn shared_object_url(
615
+ &self,
616
+ app_key: &PrivateKey,
617
+ object: &Object,
618
+ valid_until: DateTime<Utc>,
619
+ ) -> Result<Url, Error> {
620
+ let mut url = self
621
+ .url
622
+ .join(format!("objects/{}/shared", object.id()).as_str())?;
623
+
624
+ let params = self.sign(app_key, &url, Method::GET, None, valid_until);
625
+ url.set_fragment(Some(
626
+ format!(
627
+ "encryption_key={}",
628
+ URL_SAFE.encode(object.data_key().as_ref())
629
+ )
630
+ .as_str(),
631
+ ));
632
+
633
+ let mut pairs = url.query_pairs_mut();
634
+ for (key, value) in params {
635
+ pairs.append_pair(key, value.as_str());
636
+ }
637
+
638
+ Ok(pairs.finish().to_owned())
639
+ }
640
+
641
+ /// Retrieves the object metadata using a pre-signed url
642
+ ///
643
+ /// # Arguments
644
+ /// `share_url` a pre-signed url for the App objects API
645
+ ///
646
+ /// # Returns
647
+ /// A tuple with the object metadata and encryption key to decrypt
648
+ /// the user metadata.
649
+ async fn shared_object(&self, mut share_url: Url) -> Result<Object, Error> {
650
+ let encryption_key = match share_url.fragment() {
651
+ Some(fragment) => {
652
+ let fragment = match fragment.strip_prefix("encryption_key=") {
653
+ Some(fragment) => Ok(fragment),
654
+ None => Err(Error::Format("missing encryption_key".into())),
655
+ }?;
656
+ let mut out = [0u8; 32];
657
+ URL_SAFE.decode_slice(fragment, &mut out).map_err(|_| {
658
+ Error::Format("encryption key must be 32 hex-encoded bytes".into())
659
+ })?;
660
+ Ok(EncryptionKey::from(out))
661
+ }
662
+ None => Err(Error::Format("missing encryption_key".into())),
663
+ }?;
664
+ share_url.set_fragment(None);
665
+ let shared_object: SharedObjectResponse = Self::handle_response(
666
+ self.client
667
+ .get(share_url)
668
+ .timeout(Duration::from_secs(15))
669
+ .send()
670
+ .await?,
671
+ )
672
+ .await?;
673
+
674
+ Ok(Object::new(
675
+ encryption_key,
676
+ shared_object.slabs.clone(),
677
+ Vec::new(),
678
+ ))
679
+ }
680
+ }
681
+
682
+ #[cfg(test)]
683
+ mod tests {
684
+ use base64::engine::general_purpose::URL_SAFE;
685
+ use base64::prelude::*;
686
+ use chrono::FixedOffset;
687
+ use sia::signing::Signature;
688
+ use sia::{hash_256, public_key, signature};
689
+
690
+ use crate::object_id;
691
+
692
+ use super::*;
693
+ use httptest::http::Response;
694
+ use httptest::matchers::*;
695
+ use httptest::{Expectation, Server};
696
+
697
+ /// Ensures that our base64 url encoding is compatible with our Go implementation.
698
+ #[test]
699
+ fn test_base64_url() {
700
+ const DATA: &[u8] = b"hello, world!";
701
+ const ENCODED_DATA: &str = "aGVsbG8sIHdvcmxkIQ==";
702
+
703
+ let encoded = URL_SAFE.encode(DATA);
704
+ assert_eq!(encoded, ENCODED_DATA);
705
+ }
706
+
707
+ #[test]
708
+ fn test_request_hash() {
709
+ let method = Method::POST;
710
+ let url = Url::parse("https://foo.bar/foo").unwrap();
711
+ let valid_until = DateTime::from_timestamp_secs(123).unwrap();
712
+ let body = b"hello world!";
713
+ let hash = Client::request_hash(&url, method, Some(body), valid_until);
714
+ assert_eq!(
715
+ hash,
716
+ hash_256!("a9f0bda1b97b7d44ae6369ac830851a115311bb59aa2d848beda6ae95d10ad18")
717
+ )
718
+ }
719
+
720
+ #[test]
721
+ fn test_sign() {
722
+ let app_key = PrivateKey::from_seed(&[0u8; 32]);
723
+ let client = Client::new("https://foo.bar").unwrap();
724
+
725
+ // with body
726
+ let params = client.sign(
727
+ &app_key,
728
+ &"https://foo.bar/baz.jpg".parse().unwrap(),
729
+ Method::POST,
730
+ Some("{}".as_bytes()),
731
+ DateTime::from_timestamp_secs(123).unwrap() + Duration::from_secs(60),
732
+ );
733
+ assert_eq!(params[0], (QUERY_PARAM_VALID_UNTIL, "183".to_string()));
734
+ assert_eq!(
735
+ params[1],
736
+ (
737
+ QUERY_PARAM_CREDENTIAL,
738
+ URL_SAFE.encode(public_key!(
739
+ "ed25519:3b6a27bcceb6a42d62a3a8d02a6f0d73653215771de243a63ac048a18b59da29"
740
+ )),
741
+ )
742
+ );
743
+ assert_eq!(
744
+ params[2],
745
+ (
746
+ QUERY_PARAM_SIGNATURE,
747
+ URL_SAFE.encode(signature!("458283fd707c9d170d5e1814944f35893c53c9445fd46c74a6b285bf3029bf404c9af509ea271d811726bd20d8c7d8fe4b9efdc4bebb445f18059eca886ece03").as_ref()),
748
+ )
749
+ );
750
+
751
+ // without body
752
+ let params = client.sign(
753
+ &app_key,
754
+ &"https://foo.bar/baz.jpg".parse().unwrap(),
755
+ Method::GET,
756
+ None,
757
+ DateTime::from_timestamp_secs(123).unwrap() + Duration::from_secs(60),
758
+ );
759
+ assert_eq!(params[0], (QUERY_PARAM_VALID_UNTIL, "183".to_string()));
760
+ assert_eq!(
761
+ params[1],
762
+ (
763
+ QUERY_PARAM_CREDENTIAL,
764
+ URL_SAFE.encode(
765
+ public_key!(
766
+ "ed25519:3b6a27bcceb6a42d62a3a8d02a6f0d73653215771de243a63ac048a18b59da29"
767
+ )
768
+ .as_ref()
769
+ )
770
+ )
771
+ );
772
+ assert_eq!(
773
+ params[2],
774
+ (
775
+ QUERY_PARAM_SIGNATURE,
776
+ URL_SAFE.encode(signature!("7411fc80f920cb098690498133be075cd43bf6385fc8348fe1946e29d909891680d45651dfb0a6fd9f7196a971816c21441852362680f2fe4cb935de8f90380b").as_ref()),
777
+ )
778
+ );
779
+ }
780
+
781
+ #[tokio::test]
782
+ async fn test_signed_auth() {
783
+ let server = Server::run();
784
+
785
+ // expect 1 authenticated get and 1 authenticated post request
786
+ server.expect(
787
+ Expectation::matching(request::query(url_decoded(all_of![
788
+ contains((QUERY_PARAM_VALID_UNTIL, any())),
789
+ contains((QUERY_PARAM_CREDENTIAL, any())),
790
+ contains((QUERY_PARAM_SIGNATURE, any()))
791
+ ])))
792
+ .times(3)
793
+ .respond_with(
794
+ Response::builder()
795
+ .status(StatusCode::OK)
796
+ .body("{}")
797
+ .unwrap(),
798
+ ),
799
+ );
800
+
801
+ let app_key = PrivateKey::from_seed(&rand::random());
802
+ let client = Client::new(server.url("/").to_string()).unwrap();
803
+ let _: Result<(), _> = client.get_json::<_, ()>("", Some(&app_key), None).await;
804
+ let _: Result<(), _> = client.post_json::<(), ()>("", Some(&app_key), None).await;
805
+ let _: Result<(), _> = client.delete("", &app_key).await;
806
+ }
807
+
808
+ #[tokio::test]
809
+ async fn test_hosts_with_distance_sort_adds_query() {
810
+ let server = Server::run();
811
+ server.expect(
812
+ Expectation::matching(all_of![
813
+ request::method_path("GET", "/hosts"),
814
+ request::query(url_decoded(contains(("location", "(51.209300,3.224700)"))))
815
+ ])
816
+ .respond_with(
817
+ Response::builder()
818
+ .status(StatusCode::OK)
819
+ .body("[]")
820
+ .unwrap(),
821
+ ),
822
+ );
823
+
824
+ let app_key = PrivateKey::from_seed(&rand::random());
825
+ let client = Client::new(server.url("/").to_string()).unwrap();
826
+ let hosts = client
827
+ .hosts(
828
+ &app_key,
829
+ HostQuery {
830
+ location: Some(GeoLocation {
831
+ latitude: 51.2093,
832
+ longitude: 3.2247,
833
+ }),
834
+ ..Default::default()
835
+ },
836
+ )
837
+ .await
838
+ .unwrap();
839
+ assert!(hosts.is_empty());
840
+ }
841
+
842
+ #[tokio::test]
843
+ async fn test_hosts_with_additional_filters() {
844
+ let server = Server::run();
845
+ server.expect(
846
+ Expectation::matching(all_of![
847
+ request::method_path("GET", "/hosts"),
848
+ request::query(url_decoded(all_of![
849
+ contains(("offset", "5")),
850
+ contains(("limit", "25")),
851
+ contains(("protocol", "quic")),
852
+ contains(("country", "us"))
853
+ ]))
854
+ ])
855
+ .respond_with(
856
+ Response::builder()
857
+ .status(StatusCode::OK)
858
+ .body("[]")
859
+ .unwrap(),
860
+ ),
861
+ );
862
+
863
+ let app_key = PrivateKey::from_seed(&rand::random());
864
+ let client = Client::new(server.url("/").to_string()).unwrap();
865
+ let hosts = client
866
+ .hosts(
867
+ &app_key,
868
+ HostQuery {
869
+ offset: Some(5),
870
+ limit: Some(25),
871
+ protocol: Some(Protocol::QUIC),
872
+ country: Some("us".into()),
873
+ ..Default::default()
874
+ },
875
+ )
876
+ .await
877
+ .unwrap();
878
+ assert!(hosts.is_empty());
879
+ }
880
+
881
+ #[tokio::test]
882
+ async fn test_slab() {
883
+ let slab = PinnedSlab {
884
+ id: "43e424e1fc0e8b4fab0b49721d3ccb73fe1d09eef38227d9915beee623785f28"
885
+ .parse()
886
+ .unwrap(),
887
+ encryption_key: [
888
+ 186, 153, 179, 170, 159, 95, 101, 177, 15, 130, 58, 19, 138, 144, 9, 91, 181, 119,
889
+ 38, 225, 209, 47, 149, 22, 157, 210, 16, 232, 10, 151, 186, 160,
890
+ ]
891
+ .into(),
892
+ min_shards: 1,
893
+ sectors: vec![Sector {
894
+ root: hash_256!("826af7ab6471d01f4a912903a9dc23d59cff3b151059fa25615322bbf41634d6"),
895
+ host_key: public_key!(
896
+ "ed25519:910b22c360a1c67cb6a9a7371fa600c48e87d626b328669d01f34048ac3132fe"
897
+ ),
898
+ }],
899
+ };
900
+
901
+ const TEST_SLAB_JSON: &str = r#"
902
+ {
903
+ "id": "43e424e1fc0e8b4fab0b49721d3ccb73fe1d09eef38227d9915beee623785f28",
904
+ "encryptionKey": "upmzqp9fZbEPgjoTipAJW7V3JuHRL5UWndIQ6AqXuqA=",
905
+ "minShards": 1,
906
+ "sectors": [
907
+ {
908
+ "root": "826af7ab6471d01f4a912903a9dc23d59cff3b151059fa25615322bbf41634d6",
909
+ "hostKey": "ed25519:910b22c360a1c67cb6a9a7371fa600c48e87d626b328669d01f34048ac3132fe"
910
+ }
911
+ ]
912
+ }
913
+ "#;
914
+
915
+ let server = Server::run();
916
+
917
+ server.expect(
918
+ Expectation::matching(request::method_path(
919
+ "GET",
920
+ "/slabs/43e424e1fc0e8b4fab0b49721d3ccb73fe1d09eef38227d9915beee623785f28",
921
+ ))
922
+ .respond_with(
923
+ Response::builder()
924
+ .status(StatusCode::OK)
925
+ .body(TEST_SLAB_JSON)
926
+ .unwrap(),
927
+ ),
928
+ );
929
+
930
+ let app_key = PrivateKey::from_seed(&rand::random());
931
+ let client = Client::new(server.url("/").to_string()).unwrap();
932
+ assert_eq!(client.slab(&app_key, &slab.id).await.unwrap(), slab);
933
+ }
934
+
935
+ #[tokio::test]
936
+ async fn test_slab_ids() {
937
+ let slab_id = hash_256!("43e424e1fc0e8b4fab0b49721d3ccb73fe1d09eef38227d9915beee623785f28");
938
+ let server = Server::run();
939
+
940
+ server.expect(
941
+ Expectation::matching(all_of![
942
+ request::method_path("GET", "/slabs"),
943
+ request::query(url_decoded(all_of![
944
+ contains(("offset", "1")),
945
+ contains(("limit", "2"))
946
+ ]))
947
+ ])
948
+ .respond_with(
949
+ Response::builder()
950
+ .status(StatusCode::OK)
951
+ .body(format!(r#"["{slab_id}","{slab_id}"]"#))
952
+ .unwrap(),
953
+ ),
954
+ );
955
+
956
+ let app_key = PrivateKey::from_seed(&rand::random());
957
+ let client = Client::new(server.url("/").to_string()).unwrap();
958
+ assert_eq!(
959
+ client.slab_ids(&app_key, Some(1), Some(2)).await.unwrap(),
960
+ vec![slab_id, slab_id]
961
+ );
962
+ }
963
+
964
+ #[tokio::test]
965
+ async fn test_pin_slab() {
966
+ let slab_id = hash_256!("43e424e1fc0e8b4fab0b49721d3ccb73fe1d09eef38227d9915beee623785f28");
967
+ let slab = SlabPinParams {
968
+ encryption_key: [
969
+ 186, 153, 179, 170, 159, 95, 101, 177, 15, 130, 58, 19, 138, 144, 9, 91, 181, 119,
970
+ 38, 225, 209, 47, 149, 22, 157, 210, 16, 232, 10, 151, 186, 160,
971
+ ]
972
+ .into(),
973
+ min_shards: 1,
974
+ sectors: vec![Sector {
975
+ root: hash_256!("826af7ab6471d01f4a912903a9dc23d59cff3b151059fa25615322bbf41634d6"),
976
+ host_key: public_key!(
977
+ "ed25519:910b22c360a1c67cb6a9a7371fa600c48e87d626b328669d01f34048ac3132fe"
978
+ ),
979
+ }],
980
+ };
981
+ let server = Server::run();
982
+
983
+ server.expect(
984
+ Expectation::matching(all_of![
985
+ request::method_path("POST", "/slabs"),
986
+ request::body(serde_json::to_string(&vec![slab.clone()]).unwrap())
987
+ ])
988
+ .respond_with(
989
+ Response::builder()
990
+ .status(StatusCode::OK)
991
+ .body("[\"43e424e1fc0e8b4fab0b49721d3ccb73fe1d09eef38227d9915beee623785f28\"]")
992
+ .unwrap(),
993
+ ),
994
+ );
995
+
996
+ let app_key = PrivateKey::from_seed(&rand::random());
997
+ let client = Client::new(server.url("/").to_string()).unwrap();
998
+ assert_eq!(client.pin_slab(&app_key, slab).await.unwrap(), slab_id);
999
+ }
1000
+
1001
+ #[tokio::test]
1002
+ async fn test_unpin_slab() {
1003
+ let slab_id = hash_256!("43e424e1fc0e8b4fab0b49721d3ccb73fe1d09eef38227d9915beee623785f28");
1004
+ let server = Server::run();
1005
+
1006
+ server.expect(
1007
+ Expectation::matching(request::method_path("DELETE", format!("/slabs/{slab_id}")))
1008
+ .respond_with(Response::builder().status(StatusCode::OK).body("").unwrap()),
1009
+ );
1010
+
1011
+ let app_key = PrivateKey::from_seed(&rand::random());
1012
+ let client = Client::new(server.url("/").to_string()).unwrap();
1013
+ client.unpin_slab(&app_key, &slab_id).await.unwrap();
1014
+ }
1015
+
1016
+ #[tokio::test]
1017
+ async fn test_prune_slabs() {
1018
+ let server = Server::run();
1019
+
1020
+ server.expect(
1021
+ Expectation::matching(all_of![
1022
+ request::method_path("POST", "/slabs/prune"),
1023
+ request::body(""),
1024
+ ])
1025
+ .respond_with(Response::builder().status(StatusCode::OK).body("").unwrap()),
1026
+ );
1027
+
1028
+ let app_key = PrivateKey::from_seed(&rand::random());
1029
+ let client = Client::new(server.url("/").to_string()).unwrap();
1030
+ client.prune_slabs(&app_key).await.unwrap();
1031
+ }
1032
+
1033
+ #[tokio::test]
1034
+ async fn test_handle_response() {
1035
+ let server = Server::run();
1036
+ server.expect(
1037
+ Expectation::matching(any()).times(3).respond_with(
1038
+ Response::builder()
1039
+ .status(StatusCode::INTERNAL_SERVER_ERROR)
1040
+ .body("something went wrong")
1041
+ .unwrap(),
1042
+ ),
1043
+ );
1044
+
1045
+ let app_key = PrivateKey::from_seed(&rand::random());
1046
+ let client = Client::new(server.url("/").to_string()).unwrap();
1047
+
1048
+ let expected_error = Error::Api("something went wrong".to_string());
1049
+ let get_error = client
1050
+ .get_json::<(), ()>("", Some(&app_key), None)
1051
+ .await
1052
+ .unwrap_err();
1053
+ assert_eq!(get_error.to_string(), expected_error.to_string());
1054
+ let post_error = client
1055
+ .post_json::<(), ()>("", Some(&app_key), None)
1056
+ .await
1057
+ .unwrap_err();
1058
+ assert_eq!(post_error.to_string(), expected_error.to_string());
1059
+ let delete_error = client.delete("", &app_key).await.unwrap_err();
1060
+ assert_eq!(delete_error.to_string(), expected_error.to_string());
1061
+ }
1062
+
1063
+ #[tokio::test]
1064
+ async fn test_check_request_status() {
1065
+ let server = Server::run();
1066
+ server.expect(
1067
+ Expectation::matching(request::method_path("GET", "/approved")).respond_with(
1068
+ Response::builder()
1069
+ .status(StatusCode::OK)
1070
+ .body("{\"approved\": true, \"userSecret\": \"3ceeb79f58b0c4f67775e0a06aa7241c461e6844b4700a94e0a31e4d22dd02c2\"}")
1071
+ .unwrap(),
1072
+ ),
1073
+ );
1074
+ server.expect(
1075
+ Expectation::matching(request::method_path("GET", "/rejected")).respond_with(
1076
+ Response::builder()
1077
+ .status(StatusCode::NOT_FOUND)
1078
+ .body("")
1079
+ .unwrap(),
1080
+ ),
1081
+ );
1082
+ server.expect(
1083
+ Expectation::matching(request::method_path("GET", "/error")).respond_with(
1084
+ Response::builder()
1085
+ .status(StatusCode::INTERNAL_SERVER_ERROR)
1086
+ .body("something went wrong")
1087
+ .unwrap(),
1088
+ ),
1089
+ );
1090
+
1091
+ let client = Client::new("https://foo.com").unwrap();
1092
+
1093
+ // approved request
1094
+ let status_url: Url = server.url("/approved").to_string().parse().unwrap();
1095
+ assert_eq!(
1096
+ client
1097
+ .check_request_status(status_url)
1098
+ .await
1099
+ .unwrap()
1100
+ .unwrap(),
1101
+ hash_256!("3ceeb79f58b0c4f67775e0a06aa7241c461e6844b4700a94e0a31e4d22dd02c2")
1102
+ );
1103
+
1104
+ // rejected request
1105
+ let status_url: Url = server.url("/rejected").to_string().parse().unwrap();
1106
+ assert!(matches!(
1107
+ client.check_request_status(status_url).await.unwrap_err(),
1108
+ Error::UserRejected,
1109
+ ));
1110
+
1111
+ // other error
1112
+ let status_url: Url = server.url("/error").to_string().parse().unwrap();
1113
+ let err = client.check_request_status(status_url).await.unwrap_err();
1114
+ assert_eq!(
1115
+ err.to_string(),
1116
+ "indexd responded with an error: something went wrong"
1117
+ );
1118
+ }
1119
+
1120
+ #[tokio::test]
1121
+ async fn test_check_app_auth() {
1122
+ let server = Server::run();
1123
+ let app_key = PrivateKey::from_seed(&rand::random());
1124
+ let client = Client::new(server.url("").to_string()).unwrap();
1125
+
1126
+ // approved request
1127
+ server.expect(
1128
+ Expectation::matching(request::method_path("GET", "/auth/check")).respond_with(
1129
+ Response::builder()
1130
+ .status(StatusCode::NO_CONTENT)
1131
+ .body("")
1132
+ .unwrap(),
1133
+ ),
1134
+ );
1135
+ assert!(client.check_app_authenticated(&app_key).await.unwrap());
1136
+
1137
+ // rejected request
1138
+ server.expect(
1139
+ Expectation::matching(request::method_path("GET", "/auth/check")).respond_with(
1140
+ Response::builder()
1141
+ .status(StatusCode::UNAUTHORIZED)
1142
+ .body("")
1143
+ .unwrap(),
1144
+ ),
1145
+ );
1146
+ assert!(!client.check_app_authenticated(&app_key).await.unwrap());
1147
+
1148
+ // other error
1149
+ server.expect(
1150
+ Expectation::matching(request::method_path("GET", "/auth/check")).respond_with(
1151
+ Response::builder()
1152
+ .status(StatusCode::INTERNAL_SERVER_ERROR)
1153
+ .body("something went wrong")
1154
+ .unwrap(),
1155
+ ),
1156
+ );
1157
+ let err = client.check_app_authenticated(&app_key).await.unwrap_err();
1158
+ assert_eq!(
1159
+ err.to_string(),
1160
+ "indexd responded with an error: something went wrong"
1161
+ );
1162
+ }
1163
+
1164
+ #[tokio::test]
1165
+ async fn test_request_app_connection() {
1166
+ let server = Server::run();
1167
+ let app_id = {
1168
+ let buf: [u8; 32] = rand::random();
1169
+ Hash256::from(buf)
1170
+ };
1171
+ server.expect(
1172
+ Expectation::matching(all_of![
1173
+ request::method_path("POST", "/auth/connect"),
1174
+ request::body(format!(r#"{{"appID":"{app_id}","name":"name","description":"description","serviceURL":"https://service.com/","logoURL":"https://logo.com/","callbackURL":"https://callback.com/"}}"#)),
1175
+ ])
1176
+ .respond_with(Response::builder().status(StatusCode::OK).body(r#"{"responseURL":"https://response.com", "registerURL":"https://response.com","statusURL":"https://status.com","expiration":"1970-01-01T01:01:40+01:00"}"#).unwrap()),
1177
+ );
1178
+
1179
+ let client = Client::new(server.url("/").to_string()).unwrap();
1180
+
1181
+ let resp = client
1182
+ .request_app_connection(&RegisterAppRequest {
1183
+ app_id,
1184
+ name: "name".to_string(),
1185
+ description: "description".to_string(),
1186
+ service_url: "https://service.com".parse().unwrap(),
1187
+ logo_url: Some("https://logo.com".parse().unwrap()),
1188
+ callback_url: Some("https://callback.com".parse().unwrap()),
1189
+ })
1190
+ .await
1191
+ .unwrap();
1192
+
1193
+ assert_eq!(
1194
+ resp,
1195
+ RegisterAppResponse {
1196
+ register_url: "https://response.com".to_string(),
1197
+ response_url: "https://response.com".to_string(),
1198
+ status_url: "https://status.com".to_string(),
1199
+ expiration: DateTime::from_timestamp_secs(100).unwrap(),
1200
+ }
1201
+ )
1202
+ }
1203
+
1204
+ #[tokio::test]
1205
+ async fn test_object() {
1206
+ let object = SealedObject {
1207
+ encrypted_data_key: vec![1u8; 72],
1208
+ encrypted_metadata_key: vec![1u8; 72],
1209
+ encrypted_metadata: b"hello world!".to_vec(),
1210
+ data_signature: Signature::from([2u8; 64]),
1211
+ metadata_signature: Signature::from([2u8; 64]),
1212
+ slabs: vec![
1213
+ Slab {
1214
+ encryption_key: [1u8; 32].into(),
1215
+ min_shards: 1,
1216
+ sectors: vec![
1217
+ Sector {
1218
+ root: hash_256!(
1219
+ "0202020202020202020202020202020202020202020202020202020202020202"
1220
+ ),
1221
+ host_key: public_key!(
1222
+ "ed25519:0303030303030303030303030303030303030303030303030303030303030303"
1223
+ ),
1224
+ },
1225
+ Sector {
1226
+ root: hash_256!(
1227
+ "0404040404040404040404040404040404040404040404040404040404040404"
1228
+ ),
1229
+ host_key: public_key!(
1230
+ "ed25519:0505050505050505050505050505050505050505050505050505050505050505"
1231
+ ),
1232
+ },
1233
+ ],
1234
+ offset: 6,
1235
+ length: 7,
1236
+ },
1237
+ Slab {
1238
+ encryption_key: [1u8; 32].into(),
1239
+ min_shards: 1,
1240
+ sectors: vec![
1241
+ Sector {
1242
+ root: hash_256!(
1243
+ "0202020202020202020202020202020202020202020202020202020202020202"
1244
+ ),
1245
+ host_key: public_key!(
1246
+ "ed25519:0303030303030303030303030303030303030303030303030303030303030303"
1247
+ ),
1248
+ },
1249
+ Sector {
1250
+ root: hash_256!(
1251
+ "0404040404040404040404040404040404040404040404040404040404040404"
1252
+ ),
1253
+ host_key: public_key!(
1254
+ "ed25519:0505050505050505050505050505050505050505050505050505050505050505"
1255
+ ),
1256
+ },
1257
+ ],
1258
+ offset: 6,
1259
+ length: 7,
1260
+ },
1261
+ ],
1262
+ created_at: DateTime::<FixedOffset>::parse_from_rfc3339(
1263
+ "2025-09-09T16:10:46.898399-07:00",
1264
+ )
1265
+ .unwrap()
1266
+ .to_utc(),
1267
+ updated_at: DateTime::<FixedOffset>::parse_from_rfc3339(
1268
+ "2025-09-09T16:10:46.898399-07:00",
1269
+ )
1270
+ .unwrap()
1271
+ .to_utc(),
1272
+ };
1273
+
1274
+ const TEST_OBJECT_JSON: &str = r#"
1275
+ {
1276
+ "encryptedDataKey": "AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEB",
1277
+ "encryptedMetadataKey": "AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEB",
1278
+ "slabs": [
1279
+ {
1280
+ "encryptionKey": "AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=",
1281
+ "minShards": 1,
1282
+ "sectors": [
1283
+ {
1284
+ "root": "0202020202020202020202020202020202020202020202020202020202020202",
1285
+ "hostKey": "ed25519:0303030303030303030303030303030303030303030303030303030303030303"
1286
+ },
1287
+ {
1288
+ "root": "0404040404040404040404040404040404040404040404040404040404040404",
1289
+ "hostKey": "ed25519:0505050505050505050505050505050505050505050505050505050505050505"
1290
+ }
1291
+ ],
1292
+ "offset": 6,
1293
+ "length": 7
1294
+ },
1295
+ {
1296
+ "encryptionKey": "AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=",
1297
+ "minShards": 1,
1298
+ "sectors": [
1299
+ {
1300
+ "root": "0202020202020202020202020202020202020202020202020202020202020202",
1301
+ "hostKey": "ed25519:0303030303030303030303030303030303030303030303030303030303030303"
1302
+ },
1303
+ {
1304
+ "root": "0404040404040404040404040404040404040404040404040404040404040404",
1305
+ "hostKey": "ed25519:0505050505050505050505050505050505050505050505050505050505050505"
1306
+ }
1307
+ ],
1308
+ "offset": 6,
1309
+ "length": 7
1310
+ }
1311
+ ],
1312
+ "encryptedMetadata": "aGVsbG8gd29ybGQh",
1313
+ "dataSignature": "02020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202",
1314
+ "metadataSignature": "02020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202",
1315
+ "createdAt": "2025-09-09T16:10:46.898399-07:00",
1316
+ "updatedAt": "2025-09-09T16:10:46.898399-07:00"
1317
+ }
1318
+ "#;
1319
+
1320
+ let server = Server::run();
1321
+ let object_id = object.id();
1322
+
1323
+ server.expect(
1324
+ Expectation::matching(request::method_path(
1325
+ "GET",
1326
+ format!("/objects/{}", object_id),
1327
+ ))
1328
+ .respond_with(
1329
+ Response::builder()
1330
+ .status(StatusCode::OK)
1331
+ .body(TEST_OBJECT_JSON)
1332
+ .unwrap(),
1333
+ ),
1334
+ );
1335
+
1336
+ let app_key = PrivateKey::from_seed(&rand::random());
1337
+ let client = Client::new(server.url("/").to_string()).unwrap();
1338
+ assert_eq!(client.object(&app_key, &object_id).await.unwrap(), object);
1339
+ }
1340
+
1341
+ #[tokio::test]
1342
+ async fn test_objects() {
1343
+ let object = SealedObject {
1344
+ encrypted_data_key: vec![1u8; 72],
1345
+ encrypted_metadata_key: vec![1u8; 72],
1346
+ slabs: vec![
1347
+ Slab {
1348
+ encryption_key: [1u8; 32].into(),
1349
+ min_shards: 1,
1350
+ sectors: vec![
1351
+ Sector {
1352
+ root: hash_256!(
1353
+ "0202020202020202020202020202020202020202020202020202020202020202"
1354
+ ),
1355
+ host_key: public_key!(
1356
+ "ed25519:0303030303030303030303030303030303030303030303030303030303030303"
1357
+ ),
1358
+ },
1359
+ Sector {
1360
+ root: hash_256!(
1361
+ "0404040404040404040404040404040404040404040404040404040404040404"
1362
+ ),
1363
+ host_key: public_key!(
1364
+ "ed25519:0505050505050505050505050505050505050505050505050505050505050505"
1365
+ ),
1366
+ },
1367
+ ],
1368
+ offset: 0,
1369
+ length: 256,
1370
+ },
1371
+ Slab {
1372
+ encryption_key: [2u8; 32].into(),
1373
+ min_shards: 1,
1374
+ sectors: vec![
1375
+ Sector {
1376
+ root: hash_256!(
1377
+ "0202020202020202020202020202020202020202020202020202020202020202"
1378
+ ),
1379
+ host_key: public_key!(
1380
+ "ed25519:0303030303030303030303030303030303030303030303030303030303030303"
1381
+ ),
1382
+ },
1383
+ Sector {
1384
+ root: hash_256!(
1385
+ "0404040404040404040404040404040404040404040404040404040404040404"
1386
+ ),
1387
+ host_key: public_key!(
1388
+ "ed25519:0505050505050505050505050505050505050505050505050505050505050505"
1389
+ ),
1390
+ },
1391
+ ],
1392
+ offset: 256,
1393
+ length: 512,
1394
+ },
1395
+ ],
1396
+ encrypted_metadata: b"hello world!".to_vec(),
1397
+ data_signature: Signature::from([2u8; 64]),
1398
+ metadata_signature: Signature::from([2u8; 64]),
1399
+ created_at: DateTime::<FixedOffset>::parse_from_rfc3339(
1400
+ "2025-09-09T16:10:46.898399-07:00",
1401
+ )
1402
+ .unwrap()
1403
+ .to_utc(),
1404
+ updated_at: DateTime::<FixedOffset>::parse_from_rfc3339(
1405
+ "2025-09-09T16:10:46.898399-07:00",
1406
+ )
1407
+ .unwrap()
1408
+ .to_utc(),
1409
+ };
1410
+ let object_no_meta = SealedObject {
1411
+ encrypted_metadata: Vec::new(),
1412
+ ..object.clone()
1413
+ };
1414
+
1415
+ const TEST_OBJECTS_JSON: &str = r#"
1416
+ [
1417
+ {
1418
+ "key": "7f26b785c0dff73f51b81728289381064ad4b947f37417cbcb366afc3d80c7f5",
1419
+ "deleted": false,
1420
+ "updatedAt": "2025-09-09T16:10:46.898399-07:00",
1421
+ "object": {
1422
+ "encryptedDataKey": "AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEB",
1423
+ "encryptedMetadataKey": "AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEB",
1424
+ "slabs": [
1425
+ {
1426
+ "encryptionKey": "AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=",
1427
+ "minShards": 1,
1428
+ "sectors": [
1429
+ {
1430
+ "root": "0202020202020202020202020202020202020202020202020202020202020202",
1431
+ "hostKey": "ed25519:0303030303030303030303030303030303030303030303030303030303030303"
1432
+ },
1433
+ {
1434
+ "root": "0404040404040404040404040404040404040404040404040404040404040404",
1435
+ "hostKey": "ed25519:0505050505050505050505050505050505050505050505050505050505050505"
1436
+ }
1437
+ ],
1438
+ "offset": 0,
1439
+ "length": 256
1440
+ },
1441
+ {
1442
+ "encryptionKey": "AgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgI=",
1443
+ "minShards": 1,
1444
+ "sectors": [
1445
+ {
1446
+ "root": "0202020202020202020202020202020202020202020202020202020202020202",
1447
+ "hostKey": "ed25519:0303030303030303030303030303030303030303030303030303030303030303"
1448
+ },
1449
+ {
1450
+ "root": "0404040404040404040404040404040404040404040404040404040404040404",
1451
+ "hostKey": "ed25519:0505050505050505050505050505050505050505050505050505050505050505"
1452
+ }
1453
+ ],
1454
+ "offset": 256,
1455
+ "length": 512
1456
+ }
1457
+ ],
1458
+ "encryptedMetadata": "aGVsbG8gd29ybGQh",
1459
+ "dataSignature": "02020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202",
1460
+ "metadataSignature": "02020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202",
1461
+ "createdAt": "2025-09-09T16:10:46.898399-07:00",
1462
+ "updatedAt": "2025-09-09T16:10:46.898399-07:00"
1463
+ }
1464
+ },
1465
+ {
1466
+ "key": "7f26b785c0dff73f51b81728289381064ad4b947f37417cbcb366afc3d80c7f5",
1467
+ "deleted": false,
1468
+ "updatedAt": "2025-09-09T16:10:46.898399-07:00",
1469
+ "object": {
1470
+ "encryptedDataKey": "AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEB",
1471
+ "encryptedMetadataKey": "AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEB",
1472
+ "slabs": [
1473
+ {
1474
+ "encryptionKey": "AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=",
1475
+ "minShards": 1,
1476
+ "sectors": [
1477
+ {
1478
+ "root": "0202020202020202020202020202020202020202020202020202020202020202",
1479
+ "hostKey": "ed25519:0303030303030303030303030303030303030303030303030303030303030303"
1480
+ },
1481
+ {
1482
+ "root": "0404040404040404040404040404040404040404040404040404040404040404",
1483
+ "hostKey": "ed25519:0505050505050505050505050505050505050505050505050505050505050505"
1484
+ }
1485
+ ],
1486
+ "offset": 0,
1487
+ "length": 256
1488
+ },
1489
+ {
1490
+ "encryptionKey": "AgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgI=",
1491
+ "minShards": 1,
1492
+ "sectors": [
1493
+ {
1494
+ "root": "0202020202020202020202020202020202020202020202020202020202020202",
1495
+ "hostKey": "ed25519:0303030303030303030303030303030303030303030303030303030303030303"
1496
+ },
1497
+ {
1498
+ "root": "0404040404040404040404040404040404040404040404040404040404040404",
1499
+ "hostKey": "ed25519:0505050505050505050505050505050505050505050505050505050505050505"
1500
+ }
1501
+ ],
1502
+ "offset": 256,
1503
+ "length": 512
1504
+ }
1505
+ ],
1506
+ "encryptedMetadata": null,
1507
+ "dataSignature": "02020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202",
1508
+ "metadataSignature": "02020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202",
1509
+ "createdAt": "2025-09-09T16:10:46.898399-07:00",
1510
+ "updatedAt": "2025-09-09T16:10:46.898399-07:00"
1511
+ }
1512
+ },
1513
+ {
1514
+ "key": "7f26b785c0dff73f51b81728289381064ad4b947f37417cbcb366afc3d80c7f5",
1515
+ "deleted": false,
1516
+ "updatedAt": "2025-09-09T16:10:46.898399-07:00",
1517
+ "object": {
1518
+ "encryptedDataKey": "AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEB",
1519
+ "encryptedMetadataKey": "AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEB",
1520
+ "slabs": [
1521
+ {
1522
+ "encryptionKey": "AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=",
1523
+ "minShards": 1,
1524
+ "sectors": [
1525
+ {
1526
+ "root": "0202020202020202020202020202020202020202020202020202020202020202",
1527
+ "hostKey": "ed25519:0303030303030303030303030303030303030303030303030303030303030303"
1528
+ },
1529
+ {
1530
+ "root": "0404040404040404040404040404040404040404040404040404040404040404",
1531
+ "hostKey": "ed25519:0505050505050505050505050505050505050505050505050505050505050505"
1532
+ }
1533
+ ],
1534
+ "offset": 0,
1535
+ "length": 256
1536
+ },
1537
+ {
1538
+ "encryptionKey": "AgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgI=",
1539
+ "minShards": 1,
1540
+ "sectors": [
1541
+ {
1542
+ "root": "0202020202020202020202020202020202020202020202020202020202020202",
1543
+ "hostKey": "ed25519:0303030303030303030303030303030303030303030303030303030303030303"
1544
+ },
1545
+ {
1546
+ "root": "0404040404040404040404040404040404040404040404040404040404040404",
1547
+ "hostKey": "ed25519:0505050505050505050505050505050505050505050505050505050505050505"
1548
+ }
1549
+ ],
1550
+ "offset": 256,
1551
+ "length": 512
1552
+ }
1553
+ ],
1554
+ "dataSignature": "02020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202",
1555
+ "metadataSignature": "02020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202",
1556
+ "createdAt": "2025-09-09T16:10:46.898399-07:00",
1557
+ "updatedAt": "2025-09-09T16:10:46.898399-07:00"
1558
+ }
1559
+ }
1560
+ ]
1561
+ "#;
1562
+
1563
+ let server = Server::run();
1564
+ server.expect(
1565
+ Expectation::matching(all_of![
1566
+ request::method_path("GET", "/objects"),
1567
+ request::query(url_decoded(all_of![
1568
+ contains(("after", "2025-09-09T23:10:46.898399+00:00")),
1569
+ contains(("key", object.id().to_string())),
1570
+ contains(("limit", "1")),
1571
+ ]))
1572
+ ])
1573
+ .respond_with(
1574
+ Response::builder()
1575
+ .status(StatusCode::OK)
1576
+ .body(TEST_OBJECTS_JSON)
1577
+ .unwrap(),
1578
+ ),
1579
+ );
1580
+
1581
+ let app_key = PrivateKey::from_seed(&rand::random());
1582
+ let client = Client::new(server.url("/").to_string()).unwrap();
1583
+
1584
+ assert_eq!(
1585
+ client
1586
+ .objects(
1587
+ &app_key,
1588
+ Some(ObjectsCursor {
1589
+ after: object.updated_at.into(),
1590
+ id: object.id(),
1591
+ }),
1592
+ Some(1)
1593
+ )
1594
+ .await
1595
+ .unwrap(),
1596
+ vec![
1597
+ SealedObjectEvent {
1598
+ id: object.id(),
1599
+ deleted: false,
1600
+ updated_at: object.updated_at,
1601
+ object: Some(object),
1602
+ },
1603
+ SealedObjectEvent {
1604
+ id: object_no_meta.id(),
1605
+ deleted: false,
1606
+ updated_at: object_no_meta.updated_at,
1607
+ object: Some(object_no_meta.clone()),
1608
+ },
1609
+ SealedObjectEvent {
1610
+ id: object_no_meta.id(),
1611
+ deleted: false,
1612
+ updated_at: object_no_meta.updated_at,
1613
+ object: Some(object_no_meta),
1614
+ },
1615
+ ]
1616
+ );
1617
+ }
1618
+
1619
+ #[tokio::test]
1620
+ async fn delete_object() {
1621
+ let object_key =
1622
+ hash_256!("1a1fcd352cdf56f5da73a566b58d764afc8cd8bfb30ef4e786b031227356d2ef");
1623
+ let server = Server::run();
1624
+
1625
+ server.expect(
1626
+ Expectation::matching(request::method_path(
1627
+ "DELETE",
1628
+ "/objects/1a1fcd352cdf56f5da73a566b58d764afc8cd8bfb30ef4e786b031227356d2ef",
1629
+ ))
1630
+ .respond_with(Response::builder().status(StatusCode::OK).body("").unwrap()),
1631
+ );
1632
+
1633
+ let app_key = PrivateKey::from_seed(&rand::random());
1634
+ let client = Client::new(server.url("/").to_string()).unwrap();
1635
+ client.delete_object(&app_key, &object_key).await.unwrap();
1636
+ }
1637
+
1638
+ #[tokio::test]
1639
+ async fn save_object() {
1640
+ let object = SealedObject {
1641
+ encrypted_data_key: vec![1u8; 72],
1642
+ encrypted_metadata_key: vec![1u8; 72],
1643
+ data_signature: Signature::from([2u8; 64]),
1644
+ metadata_signature: Signature::from([2u8; 64]),
1645
+ slabs: vec![
1646
+ Slab {
1647
+ encryption_key: [1u8; 32].into(),
1648
+ min_shards: 2,
1649
+ sectors: vec![],
1650
+ offset: 0,
1651
+ length: 256,
1652
+ },
1653
+ Slab {
1654
+ encryption_key: [2u8; 32].into(),
1655
+ min_shards: 2,
1656
+ sectors: vec![],
1657
+ offset: 256,
1658
+ length: 512,
1659
+ },
1660
+ ],
1661
+ encrypted_metadata: b"hello world!".to_vec().into(),
1662
+ created_at: DateTime::<FixedOffset>::parse_from_rfc3339(
1663
+ "2025-09-09T16:10:46.898399-07:00",
1664
+ )
1665
+ .unwrap()
1666
+ .to_utc(),
1667
+ updated_at: DateTime::<FixedOffset>::parse_from_rfc3339(
1668
+ "2025-09-09T16:10:46.898399-07:00",
1669
+ )
1670
+ .unwrap()
1671
+ .to_utc(),
1672
+ };
1673
+
1674
+ let server = Server::run();
1675
+
1676
+ server.expect(
1677
+ Expectation::matching(all_of![
1678
+ request::method_path("POST", "/objects"),
1679
+ request::body(serde_json::to_string(&object).unwrap())
1680
+ ])
1681
+ .respond_with(Response::builder().status(StatusCode::OK).body("").unwrap()),
1682
+ );
1683
+
1684
+ let app_key = PrivateKey::from_seed(&rand::random());
1685
+ let client = Client::new(server.url("/").to_string()).unwrap();
1686
+ client.save_object(&app_key, &object).await.unwrap();
1687
+ }
1688
+
1689
+ #[test]
1690
+ fn test_shared_object_id() {
1691
+ let obj = SharedObjectResponse {
1692
+ slabs: vec![Slab {
1693
+ encryption_key: [0u8; 32].into(),
1694
+ min_shards: 1,
1695
+ sectors: vec![Sector {
1696
+ root: Hash256::new([1u8; 32]),
1697
+ host_key: PublicKey::new([2u8; 32]),
1698
+ }],
1699
+ offset: 10,
1700
+ length: 100,
1701
+ }],
1702
+ encrypted_metadata: None,
1703
+ };
1704
+
1705
+ assert_eq!(
1706
+ object_id(&obj.slabs).to_string(),
1707
+ "1b13d5dd22605af0573cae7fe9242c1ee83727c29798308b2b170864677b46d0"
1708
+ );
1709
+ }
1710
+ }