@sparkvault/sdk-mobile 0.1.3
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.
- package/README.md +186 -0
- package/dist/auth.d.ts +74 -0
- package/dist/auth.js +515 -0
- package/dist/auth.js.map +1 -0
- package/dist/billing.d.ts +21 -0
- package/dist/billing.js +10 -0
- package/dist/billing.js.map +1 -0
- package/dist/client.d.ts +27 -0
- package/dist/client.js +36 -0
- package/dist/client.js.map +1 -0
- package/dist/config.d.ts +39 -0
- package/dist/config.js +69 -0
- package/dist/config.js.map +1 -0
- package/dist/encoding.d.ts +7 -0
- package/dist/encoding.js +113 -0
- package/dist/encoding.js.map +1 -0
- package/dist/entropy.d.ts +18 -0
- package/dist/entropy.js +35 -0
- package/dist/entropy.js.map +1 -0
- package/dist/errors.d.ts +46 -0
- package/dist/errors.js +72 -0
- package/dist/errors.js.map +1 -0
- package/dist/folders.d.ts +15 -0
- package/dist/folders.js +54 -0
- package/dist/folders.js.map +1 -0
- package/dist/health.d.ts +19 -0
- package/dist/health.js +60 -0
- package/dist/health.js.map +1 -0
- package/dist/http.d.ts +45 -0
- package/dist/http.js +351 -0
- package/dist/http.js.map +1 -0
- package/dist/identity-dialog.d.ts +19 -0
- package/dist/identity-dialog.js +656 -0
- package/dist/identity-dialog.js.map +1 -0
- package/dist/index.d.ts +24 -0
- package/dist/index.js +15 -0
- package/dist/index.js.map +1 -0
- package/dist/ingots.d.ts +99 -0
- package/dist/ingots.js +365 -0
- package/dist/ingots.js.map +1 -0
- package/dist/mutex.d.ts +6 -0
- package/dist/mutex.js +20 -0
- package/dist/mutex.js.map +1 -0
- package/dist/push-tokens.d.ts +12 -0
- package/dist/push-tokens.js +15 -0
- package/dist/push-tokens.js.map +1 -0
- package/dist/sparks.d.ts +39 -0
- package/dist/sparks.js +32 -0
- package/dist/sparks.js.map +1 -0
- package/dist/tus.d.ts +24 -0
- package/dist/tus.js +202 -0
- package/dist/tus.js.map +1 -0
- package/dist/types.d.ts +406 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/validation.d.ts +5 -0
- package/dist/validation.js +45 -0
- package/dist/validation.js.map +1 -0
- package/dist/vaults.d.ts +94 -0
- package/dist/vaults.js +106 -0
- package/dist/vaults.js.map +1 -0
- package/package.json +58 -0
- package/src/auth.ts +707 -0
- package/src/billing.ts +30 -0
- package/src/client.ts +51 -0
- package/src/config.ts +123 -0
- package/src/encoding.ts +150 -0
- package/src/entropy.ts +56 -0
- package/src/errors.ts +110 -0
- package/src/folders.ts +76 -0
- package/src/health.ts +81 -0
- package/src/http.ts +429 -0
- package/src/identity-dialog.tsx +955 -0
- package/src/index.ts +103 -0
- package/src/ingots.ts +593 -0
- package/src/mutex.ts +26 -0
- package/src/push-tokens.ts +26 -0
- package/src/sparks.ts +73 -0
- package/src/tus.ts +280 -0
- package/src/types.ts +487 -0
- package/src/validation.ts +49 -0
- package/src/vaults.ts +271 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
export {
|
|
2
|
+
SparkVaultMobile,
|
|
3
|
+
createSparkVaultMobileClient,
|
|
4
|
+
} from './client.js';
|
|
5
|
+
export type {
|
|
6
|
+
SparkVaultMobileConfig,
|
|
7
|
+
ResolvedMobileConfig,
|
|
8
|
+
} from './config.js';
|
|
9
|
+
export type {
|
|
10
|
+
AuthEvent,
|
|
11
|
+
AuthEventListener,
|
|
12
|
+
} from './http.js';
|
|
13
|
+
export {
|
|
14
|
+
MobileAuthClient,
|
|
15
|
+
} from './auth.js';
|
|
16
|
+
export {
|
|
17
|
+
MobileVaultsClient,
|
|
18
|
+
} from './vaults.js';
|
|
19
|
+
export {
|
|
20
|
+
MobileFoldersClient,
|
|
21
|
+
} from './folders.js';
|
|
22
|
+
export {
|
|
23
|
+
MobileIngotsClient,
|
|
24
|
+
} from './ingots.js';
|
|
25
|
+
export type {
|
|
26
|
+
DownloadToFileOptions,
|
|
27
|
+
DownloadToFileResult,
|
|
28
|
+
UploadOptions,
|
|
29
|
+
UploadFromUriOptions,
|
|
30
|
+
ReplaceFromUriOptions,
|
|
31
|
+
} from './ingots.js';
|
|
32
|
+
export {
|
|
33
|
+
MobileHealthClient,
|
|
34
|
+
} from './health.js';
|
|
35
|
+
export type {
|
|
36
|
+
HealthCheckOptions,
|
|
37
|
+
HealthCheckResult,
|
|
38
|
+
} from './health.js';
|
|
39
|
+
export {
|
|
40
|
+
MobileSparksClient,
|
|
41
|
+
} from './sparks.js';
|
|
42
|
+
export type {
|
|
43
|
+
CreateSparkOptions,
|
|
44
|
+
CreateSparkResponse,
|
|
45
|
+
ListSparksOptions,
|
|
46
|
+
ListSparksResponse,
|
|
47
|
+
Spark,
|
|
48
|
+
SparkResponse,
|
|
49
|
+
} from './sparks.js';
|
|
50
|
+
export {
|
|
51
|
+
MobileEntropyClient,
|
|
52
|
+
} from './entropy.js';
|
|
53
|
+
export type {
|
|
54
|
+
EntropyFormat,
|
|
55
|
+
EntropyResponse,
|
|
56
|
+
GenerateEntropyOptions,
|
|
57
|
+
} from './entropy.js';
|
|
58
|
+
export {
|
|
59
|
+
MobilePushTokensClient,
|
|
60
|
+
} from './push-tokens.js';
|
|
61
|
+
export type {
|
|
62
|
+
RegisterPushTokenResponse,
|
|
63
|
+
} from './push-tokens.js';
|
|
64
|
+
export {
|
|
65
|
+
MobileBillingClient,
|
|
66
|
+
} from './billing.js';
|
|
67
|
+
export type {
|
|
68
|
+
BillingBalance,
|
|
69
|
+
} from './billing.js';
|
|
70
|
+
export {
|
|
71
|
+
MobileTusUploader,
|
|
72
|
+
parseForgeUrl,
|
|
73
|
+
} from './tus.js';
|
|
74
|
+
export type {
|
|
75
|
+
TusUploadFromUriOptions,
|
|
76
|
+
ParsedForgeUrl,
|
|
77
|
+
} from './tus.js';
|
|
78
|
+
export {
|
|
79
|
+
base64EncodeUtf8,
|
|
80
|
+
base64UrlToBytes,
|
|
81
|
+
base64UrlToUtf8,
|
|
82
|
+
bytesToUtf8,
|
|
83
|
+
base64ToBytes,
|
|
84
|
+
bytesToBase64,
|
|
85
|
+
utf8ToBytes,
|
|
86
|
+
} from './encoding.js';
|
|
87
|
+
export {
|
|
88
|
+
SparkVaultAuthenticationError,
|
|
89
|
+
SparkVaultAuthorizationError,
|
|
90
|
+
SparkVaultMobileError,
|
|
91
|
+
SparkVaultNetworkError,
|
|
92
|
+
SparkVaultTimeoutError,
|
|
93
|
+
SparkVaultValidationError,
|
|
94
|
+
TusUploadError,
|
|
95
|
+
} from './errors.js';
|
|
96
|
+
export {
|
|
97
|
+
isSafeId,
|
|
98
|
+
sanitizeFilename,
|
|
99
|
+
validateFolderId,
|
|
100
|
+
validateIngotId,
|
|
101
|
+
validateVaultId,
|
|
102
|
+
} from './validation.js';
|
|
103
|
+
export type * from './types.js';
|
package/src/ingots.ts
ADDED
|
@@ -0,0 +1,593 @@
|
|
|
1
|
+
import type { ResolvedMobileConfig } from './config.js';
|
|
2
|
+
import { SparkVaultMobileError, SparkVaultValidationError } from './errors.js';
|
|
3
|
+
import type { MobileHttpClient } from './http.js';
|
|
4
|
+
import { MobileTusUploader } from './tus.js';
|
|
5
|
+
import type {
|
|
6
|
+
DebugLogger,
|
|
7
|
+
Ingot,
|
|
8
|
+
IngotAccessLog,
|
|
9
|
+
IngotAccessStats,
|
|
10
|
+
IngotInvite,
|
|
11
|
+
IngotSharingConfig,
|
|
12
|
+
ListIngotsResponse,
|
|
13
|
+
MobileFileDownloader,
|
|
14
|
+
UploadProgressCallback,
|
|
15
|
+
UploadResult,
|
|
16
|
+
} from './types.js';
|
|
17
|
+
import { validateIngotId, validateVaultId } from './validation.js';
|
|
18
|
+
|
|
19
|
+
export interface UploadOptions {
|
|
20
|
+
vaultId: string;
|
|
21
|
+
vat: string;
|
|
22
|
+
name: string;
|
|
23
|
+
contentType: string;
|
|
24
|
+
onProgress?: UploadProgressCallback;
|
|
25
|
+
abortSignal?: AbortSignal;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface UploadFromUriOptions extends UploadOptions {
|
|
29
|
+
fileUri: string;
|
|
30
|
+
fileSize: number;
|
|
31
|
+
debug?: DebugLogger;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface ReplaceFromUriOptions extends UploadFromUriOptions {
|
|
35
|
+
ingotId: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface DownloadToFileOptions {
|
|
39
|
+
vaultId: string;
|
|
40
|
+
ingotId: string;
|
|
41
|
+
vat: string;
|
|
42
|
+
fileUri: string;
|
|
43
|
+
expectedSizeBytes?: number;
|
|
44
|
+
timeoutMs?: number;
|
|
45
|
+
retries?: number;
|
|
46
|
+
retryBaseDelayMs?: number;
|
|
47
|
+
deleteExisting?: boolean;
|
|
48
|
+
abortSignal?: AbortSignal;
|
|
49
|
+
onProgress?: UploadProgressCallback;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface DownloadToFileResult {
|
|
53
|
+
filePath: string;
|
|
54
|
+
sizeBytes: number;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
interface IngotUploadInitResponse {
|
|
58
|
+
ingot_id: string;
|
|
59
|
+
forge_url: string;
|
|
60
|
+
name?: string;
|
|
61
|
+
size_bytes?: number;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export class MobileIngotsClient {
|
|
65
|
+
private static readonly INGOT_STATUS_POLL_MAX = 10;
|
|
66
|
+
private static readonly INGOT_STATUS_POLL_MS = 1000;
|
|
67
|
+
private static readonly DOWNLOAD_MAX_ATTEMPTS = 3;
|
|
68
|
+
private static readonly DOWNLOAD_RETRY_BASE_MS = 1000;
|
|
69
|
+
|
|
70
|
+
private readonly config: ResolvedMobileConfig;
|
|
71
|
+
private readonly http: MobileHttpClient;
|
|
72
|
+
private readonly tus: MobileTusUploader;
|
|
73
|
+
|
|
74
|
+
constructor(config: ResolvedMobileConfig, http: MobileHttpClient) {
|
|
75
|
+
this.config = config;
|
|
76
|
+
this.http = http;
|
|
77
|
+
this.tus = new MobileTusUploader(config);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async uploadFromUri(options: UploadFromUriOptions): Promise<UploadResult> {
|
|
81
|
+
validateVaultId(options.vaultId);
|
|
82
|
+
if (options.abortSignal?.aborted) {
|
|
83
|
+
throw new SparkVaultMobileError('Upload cancelled', { code: 'upload_cancelled' });
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const init = await this.createIngotUpload({
|
|
87
|
+
vaultId: options.vaultId,
|
|
88
|
+
vat: options.vat,
|
|
89
|
+
name: options.name,
|
|
90
|
+
contentType: options.contentType,
|
|
91
|
+
sizeBytes: options.fileSize,
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
await this.tus.uploadFromUri({
|
|
95
|
+
fileUri: options.fileUri,
|
|
96
|
+
fileSize: options.fileSize,
|
|
97
|
+
filename: options.name,
|
|
98
|
+
contentType: options.contentType,
|
|
99
|
+
forgeUrl: init.forge_url,
|
|
100
|
+
onProgress: options.onProgress,
|
|
101
|
+
abortSignal: options.abortSignal,
|
|
102
|
+
debug: options.debug,
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
await this.verifyIngotActive(options.vaultId, init.ingot_id, options.vat);
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
ingot_id: init.ingot_id,
|
|
109
|
+
name: init.name ?? options.name,
|
|
110
|
+
size_bytes: init.size_bytes ?? options.fileSize,
|
|
111
|
+
storage_location: 's3',
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async replaceFromUri(options: ReplaceFromUriOptions): Promise<UploadResult> {
|
|
116
|
+
const { vaultId, ingotId, vat, name, contentType, fileUri, fileSize, onProgress, abortSignal, debug } = options;
|
|
117
|
+
validateVaultId(vaultId);
|
|
118
|
+
validateIngotId(ingotId);
|
|
119
|
+
|
|
120
|
+
if (abortSignal?.aborted) {
|
|
121
|
+
throw new SparkVaultMobileError('Upload cancelled', { code: 'upload_cancelled' });
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const response = await this.http.put<IngotUploadInitResponse>(
|
|
125
|
+
`/vaults/${vaultId}/ingots/${ingotId}`,
|
|
126
|
+
{ name, content_type: contentType, size_bytes: fileSize },
|
|
127
|
+
{ vat }
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
if (!response.data.ingot_id || !response.data.forge_url) {
|
|
131
|
+
throw new SparkVaultMobileError('Server returned invalid response for ingot replacement');
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
await this.tus.uploadFromUri({
|
|
135
|
+
fileUri,
|
|
136
|
+
fileSize,
|
|
137
|
+
filename: name,
|
|
138
|
+
contentType,
|
|
139
|
+
forgeUrl: response.data.forge_url,
|
|
140
|
+
onProgress,
|
|
141
|
+
abortSignal,
|
|
142
|
+
debug,
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
await this.verifyIngotActive(vaultId, response.data.ingot_id, vat);
|
|
146
|
+
|
|
147
|
+
return {
|
|
148
|
+
ingot_id: response.data.ingot_id,
|
|
149
|
+
name,
|
|
150
|
+
size_bytes: fileSize,
|
|
151
|
+
storage_location: 's3',
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async rename(vaultId: string, ingotId: string, vat: string, name: string): Promise<Ingot> {
|
|
156
|
+
validateVaultId(vaultId);
|
|
157
|
+
validateIngotId(ingotId);
|
|
158
|
+
const response = await this.http.patch<Ingot>(`/vaults/${vaultId}/ingots/${ingotId}`, { name }, { vat });
|
|
159
|
+
return response.data;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async list(
|
|
163
|
+
vaultId: string,
|
|
164
|
+
vat: string,
|
|
165
|
+
options?: { limit?: number; cursor?: string }
|
|
166
|
+
): Promise<ListIngotsResponse> {
|
|
167
|
+
validateVaultId(vaultId);
|
|
168
|
+
const params = new URLSearchParams();
|
|
169
|
+
if (options?.limit) params.append('limit', options.limit.toString());
|
|
170
|
+
if (options?.cursor) params.append('cursor', options.cursor);
|
|
171
|
+
|
|
172
|
+
const query = params.toString();
|
|
173
|
+
const response = await this.http.get<ListIngotsResponse>(
|
|
174
|
+
query ? `/vaults/${vaultId}/ingots?${query}` : `/vaults/${vaultId}/ingots`,
|
|
175
|
+
{ vat }
|
|
176
|
+
);
|
|
177
|
+
return response.data;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async listContents(
|
|
181
|
+
vaultId: string,
|
|
182
|
+
vat: string,
|
|
183
|
+
options?: {
|
|
184
|
+
folder_id?: string | null;
|
|
185
|
+
cursor?: string;
|
|
186
|
+
limit?: number;
|
|
187
|
+
sort_by?: string;
|
|
188
|
+
sort_order?: 'asc' | 'desc';
|
|
189
|
+
type?: string;
|
|
190
|
+
}
|
|
191
|
+
): Promise<ListIngotsResponse> {
|
|
192
|
+
validateVaultId(vaultId);
|
|
193
|
+
const params = new URLSearchParams();
|
|
194
|
+
if (options?.folder_id) params.append('folder_id', options.folder_id);
|
|
195
|
+
if (options?.cursor) params.append('cursor', options.cursor);
|
|
196
|
+
if (options?.limit) params.append('limit', options.limit.toString());
|
|
197
|
+
if (options?.sort_by) params.append('sort_by', options.sort_by);
|
|
198
|
+
if (options?.sort_order) params.append('sort_order', options.sort_order);
|
|
199
|
+
if (options?.type && options.type !== 'all') params.append('type', options.type);
|
|
200
|
+
|
|
201
|
+
const query = params.toString();
|
|
202
|
+
const response = await this.http.get<ListIngotsResponse>(
|
|
203
|
+
query ? `/vaults/${vaultId}/contents?${query}` : `/vaults/${vaultId}/contents`,
|
|
204
|
+
{ vat }
|
|
205
|
+
);
|
|
206
|
+
return response.data;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async search(
|
|
210
|
+
vaultId: string,
|
|
211
|
+
vat: string,
|
|
212
|
+
options?: {
|
|
213
|
+
q?: string;
|
|
214
|
+
file_type?: string;
|
|
215
|
+
min_size?: number;
|
|
216
|
+
max_size?: number;
|
|
217
|
+
limit?: number;
|
|
218
|
+
cursor?: string;
|
|
219
|
+
sort_order?: 'asc' | 'desc';
|
|
220
|
+
}
|
|
221
|
+
): Promise<ListIngotsResponse> {
|
|
222
|
+
validateVaultId(vaultId);
|
|
223
|
+
const params = new URLSearchParams();
|
|
224
|
+
if (options?.q) params.append('q', options.q);
|
|
225
|
+
if (options?.file_type && options.file_type !== 'all') params.append('file_type', options.file_type);
|
|
226
|
+
if (options?.min_size !== undefined) params.append('min_size', options.min_size.toString());
|
|
227
|
+
if (options?.max_size !== undefined) params.append('max_size', options.max_size.toString());
|
|
228
|
+
if (options?.limit) params.append('limit', options.limit.toString());
|
|
229
|
+
if (options?.cursor) params.append('cursor', options.cursor);
|
|
230
|
+
if (options?.sort_order) params.append('sort_order', options.sort_order);
|
|
231
|
+
|
|
232
|
+
const query = params.toString();
|
|
233
|
+
const response = await this.http.get<ListIngotsResponse>(
|
|
234
|
+
query ? `/vaults/${vaultId}/ingots/search?${query}` : `/vaults/${vaultId}/ingots/search`,
|
|
235
|
+
{ vat }
|
|
236
|
+
);
|
|
237
|
+
return response.data;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
async get(vaultId: string, ingotId: string, vat: string): Promise<Ingot> {
|
|
241
|
+
validateVaultId(vaultId);
|
|
242
|
+
validateIngotId(ingotId);
|
|
243
|
+
const response = await this.http.get<Ingot>(`/vaults/${vaultId}/ingots/${ingotId}`, { vat });
|
|
244
|
+
return response.data;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
async delete(vaultId: string, ingotId: string, vat: string): Promise<void> {
|
|
248
|
+
validateVaultId(vaultId);
|
|
249
|
+
validateIngotId(ingotId);
|
|
250
|
+
await this.http.delete(`/vaults/${vaultId}/ingots/${ingotId}`, { vat });
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
private async getDownloadUrl(
|
|
254
|
+
vaultId: string,
|
|
255
|
+
ingotId: string,
|
|
256
|
+
vat: string
|
|
257
|
+
): Promise<string> {
|
|
258
|
+
validateVaultId(vaultId);
|
|
259
|
+
validateIngotId(ingotId);
|
|
260
|
+
|
|
261
|
+
const response = await this.http.post<{ download_url?: string }>(
|
|
262
|
+
`/vaults/${vaultId}/ingots/${ingotId}/download`,
|
|
263
|
+
undefined,
|
|
264
|
+
{ vat }
|
|
265
|
+
);
|
|
266
|
+
|
|
267
|
+
const downloadUrl = response.data.download_url;
|
|
268
|
+
if (!downloadUrl) {
|
|
269
|
+
throw new SparkVaultMobileError('No download URL in response');
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
this.validateDownloadUrl(downloadUrl);
|
|
273
|
+
return downloadUrl;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
async downloadToFile(options: DownloadToFileOptions): Promise<DownloadToFileResult> {
|
|
277
|
+
const {
|
|
278
|
+
vaultId,
|
|
279
|
+
ingotId,
|
|
280
|
+
vat,
|
|
281
|
+
fileUri,
|
|
282
|
+
expectedSizeBytes,
|
|
283
|
+
abortSignal,
|
|
284
|
+
} = options;
|
|
285
|
+
validateVaultId(vaultId);
|
|
286
|
+
validateIngotId(ingotId);
|
|
287
|
+
this.ensureNotAborted(abortSignal);
|
|
288
|
+
|
|
289
|
+
const fileDownloader = this.config.fileDownloader;
|
|
290
|
+
if (!fileDownloader) {
|
|
291
|
+
throw new SparkVaultValidationError('fileDownloader adapter is required for file downloads');
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
if (options.deleteExisting !== false) {
|
|
295
|
+
await this.deleteFileIfPresent(fileDownloader, fileUri);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const maxAttempts = options.retries ?? MobileIngotsClient.DOWNLOAD_MAX_ATTEMPTS;
|
|
299
|
+
const retryBaseDelayMs = options.retryBaseDelayMs ?? MobileIngotsClient.DOWNLOAD_RETRY_BASE_MS;
|
|
300
|
+
let lastError: Error | null = null;
|
|
301
|
+
|
|
302
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
303
|
+
this.ensureNotAborted(abortSignal);
|
|
304
|
+
try {
|
|
305
|
+
// Fresh signed URL on every attempt — server URLs are short-lived,
|
|
306
|
+
// and retrying with a stale URL would just fail again.
|
|
307
|
+
const downloadUrl = await this.getDownloadUrl(vaultId, ingotId, vat);
|
|
308
|
+
const result = await fileDownloader.download(downloadUrl, fileUri, {
|
|
309
|
+
timeoutMs: options.timeoutMs ?? this.config.fileTransferTimeoutMs,
|
|
310
|
+
abortSignal,
|
|
311
|
+
onProgress: progress => options.onProgress?.(
|
|
312
|
+
progress.bytesWritten,
|
|
313
|
+
progress.bytesTotal ?? expectedSizeBytes ?? 0
|
|
314
|
+
),
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
const status = result?.status;
|
|
318
|
+
if (result?.uri && (status === undefined || (status >= 200 && status < 300))) {
|
|
319
|
+
return await this.verifyDownloadedFile(
|
|
320
|
+
fileDownloader,
|
|
321
|
+
result.uri,
|
|
322
|
+
fileUri,
|
|
323
|
+
result.sizeBytes,
|
|
324
|
+
expectedSizeBytes
|
|
325
|
+
);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
await this.deleteFileIfPresent(fileDownloader, fileUri);
|
|
329
|
+
|
|
330
|
+
const statusCode = result?.status;
|
|
331
|
+
const statusLabel = statusCode ?? 'unknown';
|
|
332
|
+
lastError = new SparkVaultMobileError(`Download failed with status ${statusLabel}`, {
|
|
333
|
+
code: statusLabel,
|
|
334
|
+
statusCode,
|
|
335
|
+
});
|
|
336
|
+
} catch (err) {
|
|
337
|
+
if (this.isAbortError(err) || abortSignal?.aborted) {
|
|
338
|
+
await this.deleteFileIfPresent(fileDownloader, fileUri);
|
|
339
|
+
throw new SparkVaultMobileError('Download cancelled', { code: 'download_cancelled' });
|
|
340
|
+
}
|
|
341
|
+
lastError = err instanceof Error ? err : new Error(String(err));
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
if (!this.isRetryableDownloadError(lastError) || attempt === maxAttempts) {
|
|
345
|
+
throw lastError;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
await this.delay(retryBaseDelayMs * 2 ** (attempt - 1), abortSignal);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
throw lastError ?? new SparkVaultMobileError('Download failed');
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
async getSharingConfig(vaultId: string, ingotId: string, vat: string): Promise<IngotSharingConfig> {
|
|
355
|
+
validateVaultId(vaultId);
|
|
356
|
+
validateIngotId(ingotId);
|
|
357
|
+
const response = await this.http.get<IngotSharingConfig>(
|
|
358
|
+
`/vaults/${vaultId}/ingots/${ingotId}/sharing`,
|
|
359
|
+
{ vat }
|
|
360
|
+
);
|
|
361
|
+
return response.data;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
async updateSharingConfig(
|
|
365
|
+
vaultId: string,
|
|
366
|
+
ingotId: string,
|
|
367
|
+
vat: string,
|
|
368
|
+
config: {
|
|
369
|
+
shared?: boolean;
|
|
370
|
+
visibility?: 'invite_only' | 'authenticated' | 'public';
|
|
371
|
+
}
|
|
372
|
+
): Promise<IngotSharingConfig> {
|
|
373
|
+
validateVaultId(vaultId);
|
|
374
|
+
validateIngotId(ingotId);
|
|
375
|
+
const response = await this.http.put<IngotSharingConfig>(
|
|
376
|
+
`/vaults/${vaultId}/ingots/${ingotId}/sharing`,
|
|
377
|
+
config,
|
|
378
|
+
{ vat }
|
|
379
|
+
);
|
|
380
|
+
return response.data;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
async addInvite(
|
|
384
|
+
vaultId: string,
|
|
385
|
+
ingotId: string,
|
|
386
|
+
vat: string,
|
|
387
|
+
identity: string,
|
|
388
|
+
identityType: 'email' = 'email'
|
|
389
|
+
): Promise<IngotInvite> {
|
|
390
|
+
validateVaultId(vaultId);
|
|
391
|
+
validateIngotId(ingotId);
|
|
392
|
+
const response = await this.http.post<IngotInvite>(
|
|
393
|
+
`/vaults/${vaultId}/ingots/${ingotId}/sharing/invite`,
|
|
394
|
+
{ identity, identity_type: identityType },
|
|
395
|
+
{ vat }
|
|
396
|
+
);
|
|
397
|
+
return response.data;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
async revokeInvite(vaultId: string, ingotId: string, vat: string, inviteId: string): Promise<void> {
|
|
401
|
+
validateVaultId(vaultId);
|
|
402
|
+
validateIngotId(ingotId);
|
|
403
|
+
await this.http.delete(`/vaults/${vaultId}/ingots/${ingotId}/sharing/invite/${inviteId}`, { vat });
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
async getAccessLogs(
|
|
407
|
+
vaultId: string,
|
|
408
|
+
ingotId: string,
|
|
409
|
+
vat: string,
|
|
410
|
+
options?: { limit?: number; cursor?: string }
|
|
411
|
+
): Promise<{ logs: IngotAccessLog[]; next_cursor?: string }> {
|
|
412
|
+
validateVaultId(vaultId);
|
|
413
|
+
validateIngotId(ingotId);
|
|
414
|
+
const params = new URLSearchParams();
|
|
415
|
+
if (options?.limit) params.append('limit', options.limit.toString());
|
|
416
|
+
if (options?.cursor) params.append('cursor', options.cursor);
|
|
417
|
+
|
|
418
|
+
const query = params.toString();
|
|
419
|
+
const response = await this.http.get<{ logs: IngotAccessLog[]; next_cursor?: string }>(
|
|
420
|
+
query
|
|
421
|
+
? `/vaults/${vaultId}/ingots/${ingotId}/access-logs?${query}`
|
|
422
|
+
: `/vaults/${vaultId}/ingots/${ingotId}/access-logs`,
|
|
423
|
+
{ vat }
|
|
424
|
+
);
|
|
425
|
+
return response.data;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
async getAccessStats(vaultId: string, ingotId: string, vat: string): Promise<IngotAccessStats> {
|
|
429
|
+
validateVaultId(vaultId);
|
|
430
|
+
validateIngotId(ingotId);
|
|
431
|
+
const response = await this.http.get<IngotAccessStats>(
|
|
432
|
+
`/vaults/${vaultId}/ingots/${ingotId}/access-stats`,
|
|
433
|
+
{ vat }
|
|
434
|
+
);
|
|
435
|
+
return response.data;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
async purgeAccessLogs(vaultId: string, ingotId: string, vat: string): Promise<void> {
|
|
439
|
+
validateVaultId(vaultId);
|
|
440
|
+
validateIngotId(ingotId);
|
|
441
|
+
await this.http.delete(`/vaults/${vaultId}/ingots/${ingotId}/access-logs`, { vat });
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
private async createIngotUpload(options: {
|
|
445
|
+
vaultId: string;
|
|
446
|
+
vat: string;
|
|
447
|
+
name: string;
|
|
448
|
+
contentType: string;
|
|
449
|
+
sizeBytes: number;
|
|
450
|
+
}): Promise<IngotUploadInitResponse> {
|
|
451
|
+
const response = await this.http.post<IngotUploadInitResponse>(
|
|
452
|
+
`/vaults/${options.vaultId}/ingots`,
|
|
453
|
+
{
|
|
454
|
+
name: options.name,
|
|
455
|
+
content_type: options.contentType,
|
|
456
|
+
size_bytes: options.sizeBytes,
|
|
457
|
+
},
|
|
458
|
+
{ vat: options.vat }
|
|
459
|
+
);
|
|
460
|
+
|
|
461
|
+
if (!response.data.ingot_id || !response.data.forge_url) {
|
|
462
|
+
throw new SparkVaultMobileError('Server returned invalid response for ingot creation');
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
return response.data;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
private async verifyIngotActive(vaultId: string, ingotId: string, vat: string): Promise<Ingot> {
|
|
469
|
+
let ingot: Ingot | null = null;
|
|
470
|
+
|
|
471
|
+
for (let attempt = 1; attempt <= MobileIngotsClient.INGOT_STATUS_POLL_MAX; attempt++) {
|
|
472
|
+
ingot = await this.get(vaultId, ingotId, vat);
|
|
473
|
+
|
|
474
|
+
if (ingot.status === 'active') {
|
|
475
|
+
return ingot;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
if (ingot.status === 'failed') {
|
|
479
|
+
throw new SparkVaultMobileError('Upload failed: server encryption error');
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
if (attempt < MobileIngotsClient.INGOT_STATUS_POLL_MAX) {
|
|
483
|
+
await new Promise(resolve => setTimeout(resolve, MobileIngotsClient.INGOT_STATUS_POLL_MS));
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
throw new SparkVaultMobileError(`Upload failed: ingot status is ${ingot?.status ?? 'unknown'}`);
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
private async verifyDownloadedFile(
|
|
491
|
+
fileDownloader: MobileFileDownloader,
|
|
492
|
+
preferredUri: string,
|
|
493
|
+
expectedUri: string,
|
|
494
|
+
knownSizeBytes?: number,
|
|
495
|
+
expectedSizeBytes?: number
|
|
496
|
+
): Promise<DownloadToFileResult> {
|
|
497
|
+
const filePath = preferredUri || expectedUri;
|
|
498
|
+
const info = await fileDownloader.getInfo(filePath);
|
|
499
|
+
if (!info.exists) {
|
|
500
|
+
throw new SparkVaultMobileError('Download failed - file does not exist');
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
const sizeBytes = info.sizeBytes ?? knownSizeBytes ?? 0;
|
|
504
|
+
if (sizeBytes <= 0) {
|
|
505
|
+
await this.deleteFileIfPresent(fileDownloader, filePath);
|
|
506
|
+
throw new SparkVaultMobileError('Download failed - file is empty');
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
if (expectedSizeBytes !== undefined && expectedSizeBytes > 0 && sizeBytes !== expectedSizeBytes) {
|
|
510
|
+
await this.deleteFileIfPresent(fileDownloader, filePath);
|
|
511
|
+
// Deterministic server-content mismatch — retrying the same ingot will
|
|
512
|
+
// yield the same bytes, so this must surface as a non-retryable validation
|
|
513
|
+
// failure rather than a transient transport error.
|
|
514
|
+
throw new SparkVaultValidationError('Download failed - file size did not match expected ingot size', {
|
|
515
|
+
expectedSizeBytes,
|
|
516
|
+
actualSizeBytes: sizeBytes,
|
|
517
|
+
});
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
return { filePath, sizeBytes };
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
private async deleteFileIfPresent(fileDownloader: MobileFileDownloader, fileUri: string): Promise<void> {
|
|
524
|
+
await fileDownloader.delete(fileUri).catch(() => undefined);
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
private ensureNotAborted(signal?: AbortSignal): void {
|
|
528
|
+
if (signal?.aborted) {
|
|
529
|
+
throw new SparkVaultMobileError('Download cancelled', { code: 'download_cancelled' });
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
private isAbortError(err: unknown): boolean {
|
|
534
|
+
return err instanceof Error && (
|
|
535
|
+
err.name === 'AbortError' ||
|
|
536
|
+
err.message.toLowerCase().includes('abort') ||
|
|
537
|
+
err.message.toLowerCase().includes('cancelled')
|
|
538
|
+
);
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
private isRetryableDownloadError(err: Error): boolean {
|
|
542
|
+
// Validation errors (bad URL, bad size, bad host) won't be fixed by retrying.
|
|
543
|
+
if (err instanceof SparkVaultValidationError) return false;
|
|
544
|
+
const status = (err as Error & { statusCode?: number }).statusCode;
|
|
545
|
+
if (typeof status === 'number' && status >= 400 && status < 500 && status !== 408 && status !== 429) {
|
|
546
|
+
return false;
|
|
547
|
+
}
|
|
548
|
+
return true;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
private delay(ms: number, signal?: AbortSignal): Promise<void> {
|
|
552
|
+
return new Promise((resolve, reject) => {
|
|
553
|
+
const timeout = setTimeout(() => {
|
|
554
|
+
signal?.removeEventListener('abort', onAbort);
|
|
555
|
+
resolve();
|
|
556
|
+
}, ms);
|
|
557
|
+
const onAbort = () => {
|
|
558
|
+
clearTimeout(timeout);
|
|
559
|
+
reject(new SparkVaultMobileError('Download cancelled', { code: 'download_cancelled' }));
|
|
560
|
+
};
|
|
561
|
+
if (signal?.aborted) {
|
|
562
|
+
clearTimeout(timeout);
|
|
563
|
+
reject(new SparkVaultMobileError('Download cancelled', { code: 'download_cancelled' }));
|
|
564
|
+
return;
|
|
565
|
+
}
|
|
566
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
567
|
+
});
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
private validateDownloadUrl(downloadUrl: string): void {
|
|
571
|
+
let url: URL;
|
|
572
|
+
try {
|
|
573
|
+
url = new URL(downloadUrl);
|
|
574
|
+
} catch {
|
|
575
|
+
throw new SparkVaultValidationError('Invalid download URL from server');
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
if (url.protocol !== 'https:') {
|
|
579
|
+
throw new SparkVaultValidationError('Invalid download URL from server', {
|
|
580
|
+
reason: 'non_https_download_url',
|
|
581
|
+
});
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
const allowed = this.config.allowedDownloadHostPatterns.some(pattern => pattern.test(url.hostname));
|
|
585
|
+
if (!allowed) {
|
|
586
|
+
throw new SparkVaultValidationError('Invalid download URL from server', {
|
|
587
|
+
reason: 'download_host_not_allowed',
|
|
588
|
+
hostname: url.hostname,
|
|
589
|
+
});
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
}
|