@sparkvault/sdk-mobile 6.0.1 → 7.0.0
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 +5 -5
- package/dist/account.d.ts +4 -3
- package/dist/account.js +16 -11
- package/dist/account.js.map +1 -1
- package/dist/app-sdk/email-address.d.ts +37 -0
- package/dist/app-sdk/email-address.js +91 -0
- package/dist/app-sdk/email-address.js.map +1 -0
- package/dist/app-sdk/encoding.d.ts +64 -0
- package/dist/app-sdk/encoding.js +245 -0
- package/dist/app-sdk/encoding.js.map +1 -0
- package/dist/app-sdk/entropy-contract.d.ts +33 -0
- package/dist/app-sdk/entropy-contract.js +51 -0
- package/dist/app-sdk/entropy-contract.js.map +1 -0
- package/dist/app-sdk/filename.d.ts +93 -0
- package/dist/app-sdk/filename.js +161 -0
- package/dist/app-sdk/filename.js.map +1 -0
- package/dist/app-sdk/forge-url.d.ts +107 -0
- package/dist/app-sdk/forge-url.js +222 -0
- package/dist/app-sdk/forge-url.js.map +1 -0
- package/dist/app-sdk/ids.d.ts +48 -0
- package/dist/app-sdk/ids.js +65 -0
- package/dist/app-sdk/ids.js.map +1 -0
- package/dist/app-sdk/ingot-access-log.d.ts +29 -0
- package/dist/app-sdk/ingot-access-log.js +49 -0
- package/dist/app-sdk/ingot-access-log.js.map +1 -0
- package/dist/app-sdk/phone.d.ts +37 -0
- package/dist/app-sdk/phone.js +134 -0
- package/dist/app-sdk/phone.js.map +1 -0
- package/dist/app-sdk/platform-domains.d.ts +41 -0
- package/dist/app-sdk/platform-domains.js +82 -0
- package/dist/app-sdk/platform-domains.js.map +1 -0
- package/dist/app-sdk/request-path.d.ts +49 -0
- package/dist/app-sdk/request-path.js +81 -0
- package/dist/app-sdk/request-path.js.map +1 -0
- package/dist/app-sdk/transient.d.ts +23 -0
- package/dist/app-sdk/transient.js +29 -0
- package/dist/app-sdk/transient.js.map +1 -0
- package/dist/app-sdk/vat-refusal.d.ts +66 -0
- package/dist/app-sdk/vat-refusal.js +69 -0
- package/dist/app-sdk/vat-refusal.js.map +1 -0
- package/dist/billing.d.ts +9 -17
- package/dist/billing.js +14 -17
- package/dist/billing.js.map +1 -1
- package/dist/config.d.ts +7 -1
- package/dist/config.js +10 -17
- package/dist/config.js.map +1 -1
- package/dist/encoding.d.ts +30 -4
- package/dist/encoding.js +89 -83
- package/dist/encoding.js.map +1 -1
- package/dist/entropy.d.ts +25 -4
- package/dist/entropy.js +62 -15
- package/dist/entropy.js.map +1 -1
- package/dist/entropy.test-d.d.ts +40 -0
- package/dist/entropy.test-d.js +12 -0
- package/dist/entropy.test-d.js.map +1 -0
- package/dist/errors.d.ts +67 -17
- package/dist/errors.js +70 -27
- package/dist/errors.js.map +1 -1
- package/dist/folders.js +8 -7
- package/dist/folders.js.map +1 -1
- package/dist/http.d.ts +57 -15
- package/dist/http.js +125 -138
- package/dist/http.js.map +1 -1
- package/dist/identity-dialog.js +4 -30
- package/dist/identity-dialog.js.map +1 -1
- package/dist/identity-input.d.ts +39 -0
- package/dist/identity-input.js +59 -0
- package/dist/identity-input.js.map +1 -0
- package/dist/index.d.ts +6 -5
- package/dist/index.js +4 -3
- package/dist/index.js.map +1 -1
- package/dist/ingots.d.ts +58 -19
- package/dist/ingots.js +145 -125
- package/dist/ingots.js.map +1 -1
- package/dist/mutex.d.ts +1 -2
- package/dist/mutex.js +0 -6
- package/dist/mutex.js.map +1 -1
- package/dist/notify.d.ts +7 -2
- package/dist/notify.js +12 -15
- package/dist/notify.js.map +1 -1
- package/dist/notify.test-d.d.ts +3 -2
- package/dist/notify.test-d.js +1 -1
- package/dist/products/identity.d.ts +5 -0
- package/dist/products/identity.js +40 -35
- package/dist/products/identity.js.map +1 -1
- package/dist/request-path.d.ts +32 -0
- package/dist/request-path.js +35 -0
- package/dist/request-path.js.map +1 -0
- package/dist/sparks.js +16 -15
- package/dist/sparks.js.map +1 -1
- package/dist/tus.d.ts +19 -2
- package/dist/tus.js +86 -35
- package/dist/tus.js.map +1 -1
- package/dist/types.d.ts +169 -15
- package/dist/users.d.ts +12 -5
- package/dist/users.js +27 -19
- package/dist/users.js.map +1 -1
- package/dist/validation.d.ts +20 -5
- package/dist/validation.js +29 -27
- package/dist/validation.js.map +1 -1
- package/dist/vaults.d.ts +34 -83
- package/dist/vaults.js +64 -44
- package/dist/vaults.js.map +1 -1
- package/dist/vaults.test-d.d.ts +22 -0
- package/dist/vaults.test-d.js +14 -0
- package/dist/vaults.test-d.js.map +1 -0
- package/package.json +4 -2
- package/src/account.ts +16 -11
- package/src/app-sdk/email-address.js +94 -0
- package/src/app-sdk/encoding.js +250 -0
- package/src/app-sdk/entropy-contract.js +56 -0
- package/src/app-sdk/filename.js +174 -0
- package/src/app-sdk/forge-url.js +224 -0
- package/src/app-sdk/ids.js +71 -0
- package/src/app-sdk/ingot-access-log.js +51 -0
- package/src/app-sdk/phone.js +139 -0
- package/src/app-sdk/platform-domains.js +99 -0
- package/src/app-sdk/request-path.js +81 -0
- package/src/app-sdk/transient.js +30 -0
- package/src/app-sdk/vat-refusal.js +74 -0
- package/src/billing.ts +16 -25
- package/src/config.ts +16 -23
- package/src/encoding.ts +91 -103
- package/src/entropy.test-d.ts +47 -0
- package/src/entropy.ts +86 -20
- package/src/errors.ts +93 -33
- package/src/folders.ts +8 -7
- package/src/http.ts +145 -150
- package/src/identity-dialog.tsx +4 -37
- package/src/identity-input.ts +70 -0
- package/src/index.ts +11 -3
- package/src/ingots.ts +191 -163
- package/src/mutex.ts +1 -8
- package/src/notify.test-d.ts +3 -1
- package/src/notify.ts +19 -17
- package/src/products/identity.ts +45 -39
- package/src/request-path.ts +45 -0
- package/src/sparks.ts +16 -13
- package/src/tus.ts +90 -37
- package/src/types.ts +167 -15
- package/src/users.ts +27 -17
- package/src/validation.ts +30 -24
- package/src/vaults.test-d.ts +29 -0
- package/src/vaults.ts +87 -153
- package/dist/platform-domains.generated.d.ts +0 -2
- package/dist/platform-domains.generated.js +0 -16
- package/dist/platform-domains.generated.js.map +0 -1
- package/src/platform-domains.generated.ts +0 -17
package/src/ingots.ts
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
|
+
import { readForgeTransferUrl } from './app-sdk/forge-url.js';
|
|
2
|
+
import { INGOT_ACCESS_EVENT_TYPES, readIngotAccess } from './app-sdk/ingot-access-log.js';
|
|
1
3
|
import type { ResolvedMobileConfig } from './config.js';
|
|
2
4
|
import {
|
|
5
|
+
IngotNameHeldError,
|
|
3
6
|
IngotNotReadyError,
|
|
4
7
|
isRetryableDownloadError,
|
|
5
8
|
SparkVaultMobileError,
|
|
6
9
|
SparkVaultValidationError,
|
|
7
10
|
} from './errors.js';
|
|
8
11
|
import type { MobileHttpClient } from './http.js';
|
|
9
|
-
import { MobileTusUploader } from './tus.js';
|
|
12
|
+
import { allowsHost, MobileTusUploader } from './tus.js';
|
|
10
13
|
import type {
|
|
11
14
|
DebugLogger,
|
|
12
15
|
HashCheckResponse,
|
|
@@ -16,6 +19,7 @@ import type {
|
|
|
16
19
|
IngotInvite,
|
|
17
20
|
IngotSharingConfig,
|
|
18
21
|
ListIngotsResponse,
|
|
22
|
+
FolderContentsResponse,
|
|
19
23
|
IngotUploadSession,
|
|
20
24
|
MobileFileDownloader,
|
|
21
25
|
UploadAbortBehavior,
|
|
@@ -24,7 +28,26 @@ import type {
|
|
|
24
28
|
UploadTransferPolicy,
|
|
25
29
|
UploadTransport,
|
|
26
30
|
} from './types.js';
|
|
27
|
-
import { validateIngotId, validateVaultId } from './validation.js';
|
|
31
|
+
import { validateIngotId, validateResourceId, validateVaultId } from './validation.js';
|
|
32
|
+
import { apiPath } from './request-path.js';
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* What the server does when a create's name is already held in the target
|
|
36
|
+
* folder (case-insensitive). The server decides on the holder it read before
|
|
37
|
+
* writing anything, so a caller that must not replace content says so here:
|
|
38
|
+
* - `upsert` (the server default): the upload replaces the holder's content.
|
|
39
|
+
* - `complete_pending`: a holder whose own upload never finalized is completed
|
|
40
|
+
* (`created: false`); a published holder is refused with
|
|
41
|
+
* `IngotNameHeldError`, untouched.
|
|
42
|
+
* - `refuse`: any holder, its upload finished or not, is refused with
|
|
43
|
+
* `IngotNameHeldError`, untouched; the create only ever mints.
|
|
44
|
+
* - `new_name`: the holder is never touched; the ingot is minted under the
|
|
45
|
+
* first free numbered name the server picks ("report (2).pdf"), returned
|
|
46
|
+
* as the result's `name`.
|
|
47
|
+
*/
|
|
48
|
+
export const NAME_CONFLICT_POLICIES = ['upsert', 'complete_pending', 'refuse', 'new_name'] as const;
|
|
49
|
+
|
|
50
|
+
export type NameConflictPolicy = (typeof NAME_CONFLICT_POLICIES)[number];
|
|
28
51
|
|
|
29
52
|
export interface UploadOptions {
|
|
30
53
|
vaultId: string;
|
|
@@ -55,14 +78,19 @@ export interface UploadOptions {
|
|
|
55
78
|
export interface UploadFromUriOptions extends UploadOptions {
|
|
56
79
|
fileUri: string;
|
|
57
80
|
fileSize: number;
|
|
81
|
+
/** What a name already held in the target folder does; omitted means the server's `upsert`. */
|
|
82
|
+
onNameConflict?: NameConflictPolicy;
|
|
58
83
|
/**
|
|
59
|
-
* Invoked once the
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
84
|
+
* Invoked once the server has placed the upload and before any Forge
|
|
85
|
+
* session exists: `created` is true when it minted the ingot and false when
|
|
86
|
+
* it staged the upload onto the row holding the name (as `onNameConflict`
|
|
87
|
+
* allowed), and `name` is the name the ingot is stored under (the server's
|
|
88
|
+
* numbered name under `new_name`). The placement has already been written
|
|
89
|
+
* by then, so this hook observes; a caller that must not replace content
|
|
90
|
+
* says so through `onNameConflict`. A throw here still stops the upload
|
|
91
|
+
* before a byte moves and propagates as-is.
|
|
64
92
|
*/
|
|
65
|
-
onIngotCreated?: (ingot: { ingotId: string; created: boolean }) => void;
|
|
93
|
+
onIngotCreated?: (ingot: { ingotId: string; created: boolean; name: string }) => void;
|
|
66
94
|
/**
|
|
67
95
|
* Invoked once the Forge tus session exists and before the first byte is
|
|
68
96
|
* sent, so the caller can persist it and `resumeUpload` after a failed
|
|
@@ -86,7 +114,8 @@ export interface UploadFromUriOptions extends UploadOptions {
|
|
|
86
114
|
debug?: DebugLogger;
|
|
87
115
|
}
|
|
88
116
|
|
|
89
|
-
|
|
117
|
+
/** A replace addresses its ingot by id: no name is placed, so the create-only hooks do not apply. */
|
|
118
|
+
export interface ReplaceFromUriOptions extends Omit<UploadFromUriOptions, 'onNameConflict' | 'onIngotCreated'> {
|
|
90
119
|
ingotId: string;
|
|
91
120
|
}
|
|
92
121
|
|
|
@@ -98,7 +127,6 @@ export interface ResumeUploadOptions {
|
|
|
98
127
|
fileUri: string;
|
|
99
128
|
fileSize: number;
|
|
100
129
|
name: string;
|
|
101
|
-
contentType: string;
|
|
102
130
|
/** The persisted tus session handed to `onSessionCreated`. */
|
|
103
131
|
session: { uploadUrl: string; istk: string };
|
|
104
132
|
/** See `UploadTransport`; default `foreground`. */
|
|
@@ -121,6 +149,8 @@ export interface DownloadToFileOptions {
|
|
|
121
149
|
vat: string;
|
|
122
150
|
fileUri: string;
|
|
123
151
|
expectedSizeBytes?: number;
|
|
152
|
+
/** Revision from the current metadata read, when available. */
|
|
153
|
+
expectedContentVersion?: string | null;
|
|
124
154
|
timeoutMs?: number;
|
|
125
155
|
retries?: number;
|
|
126
156
|
retryBaseDelayMs?: number;
|
|
@@ -130,6 +160,7 @@ export interface DownloadToFileOptions {
|
|
|
130
160
|
}
|
|
131
161
|
|
|
132
162
|
export interface DownloadToFileResult {
|
|
163
|
+
contentVersion?: string | null;
|
|
133
164
|
filePath: string;
|
|
134
165
|
sizeBytes: number;
|
|
135
166
|
}
|
|
@@ -158,17 +189,20 @@ export interface CreateUploadOptions {
|
|
|
158
189
|
folderId?: string;
|
|
159
190
|
/** Vault-keyed plaintext HMAC (43-char base64url); see UploadOptions.contentHmac. */
|
|
160
191
|
contentHmac?: string;
|
|
192
|
+
/** What a name already held in the target folder does; omitted means the server's `upsert`. */
|
|
193
|
+
onNameConflict?: NameConflictPolicy;
|
|
161
194
|
}
|
|
162
195
|
|
|
163
196
|
export interface CreateUploadResult {
|
|
164
197
|
ingotId: string;
|
|
165
198
|
/** Forge tus endpoint plus upload-session token (ISTK) for the resumable upload. */
|
|
166
199
|
forgeUrl: string;
|
|
200
|
+
/** The name the ingot is stored under: the server's numbered name under `new_name`. */
|
|
167
201
|
name?: string;
|
|
168
202
|
sizeBytes?: number;
|
|
169
203
|
/**
|
|
170
|
-
* Whether the server minted this ingot (true) or
|
|
171
|
-
* row
|
|
204
|
+
* Whether the server minted this ingot (true) or staged the upload onto
|
|
205
|
+
* the row already holding the name (false). Absent when the server did not say.
|
|
172
206
|
*/
|
|
173
207
|
created?: boolean;
|
|
174
208
|
}
|
|
@@ -180,6 +214,7 @@ export interface CreateDownloadLinkOptions {
|
|
|
180
214
|
}
|
|
181
215
|
|
|
182
216
|
export interface CreateDownloadLinkResult {
|
|
217
|
+
contentVersion: string | null;
|
|
183
218
|
/** Short-lived signed download URL, validated against the allowed-host policy. */
|
|
184
219
|
downloadUrl: string;
|
|
185
220
|
}
|
|
@@ -192,29 +227,23 @@ interface IngotUploadInitResponse {
|
|
|
192
227
|
created?: boolean;
|
|
193
228
|
}
|
|
194
229
|
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
actor_id?: string;
|
|
209
|
-
identity?: string;
|
|
210
|
-
ip_address?: string;
|
|
211
|
-
user_agent?: string;
|
|
212
|
-
timestamp?: number;
|
|
213
|
-
created_at?: number;
|
|
230
|
+
/** An ingot audit entry in the server's public shape. */
|
|
231
|
+
interface IngotAuditEntry {
|
|
232
|
+
timestamp: number;
|
|
233
|
+
event_type: string;
|
|
234
|
+
actor_id?: string | null;
|
|
235
|
+
actor_type?: string;
|
|
236
|
+
actor_display?: string;
|
|
237
|
+
ip_address?: string | null;
|
|
238
|
+
user_agent?: string | null;
|
|
239
|
+
metadata?: { identity?: string | null; [key: string]: unknown } | null;
|
|
240
|
+
encrypted?: boolean;
|
|
241
|
+
vault_id?: string;
|
|
242
|
+
ingot_id?: string;
|
|
214
243
|
}
|
|
215
244
|
|
|
216
245
|
interface IngotAuditLogsResponse {
|
|
217
|
-
entries?:
|
|
246
|
+
entries?: IngotAuditEntry[];
|
|
218
247
|
next_cursor?: string;
|
|
219
248
|
}
|
|
220
249
|
|
|
@@ -239,6 +268,28 @@ function toTypedDownloadError(err: unknown): unknown {
|
|
|
239
268
|
return err;
|
|
240
269
|
}
|
|
241
270
|
|
|
271
|
+
/**
|
|
272
|
+
* A create refused over its holder (`complete_pending` over a published one,
|
|
273
|
+
* `refuse` over any) answers 409 CONFLICT with `details.reason === 'NAME_HELD'`
|
|
274
|
+
* and the holder the server read. Every other 409 on the create (a folder
|
|
275
|
+
* being deleted, a cross-manager name) carries no holder and stays a plain
|
|
276
|
+
* `SparkVaultMobileError`.
|
|
277
|
+
*/
|
|
278
|
+
function toTypedCreateError(err: unknown): unknown {
|
|
279
|
+
if (!(err instanceof SparkVaultMobileError) || err.statusCode !== 409 || err.details?.reason !== 'NAME_HELD') {
|
|
280
|
+
return err;
|
|
281
|
+
}
|
|
282
|
+
const { ingot_id: ingotId, status, size_bytes: sizeBytes } = err.details;
|
|
283
|
+
if (typeof ingotId !== 'string' || typeof status !== 'string' || typeof sizeBytes !== 'number') {
|
|
284
|
+
return err;
|
|
285
|
+
}
|
|
286
|
+
return new IngotNameHeldError(err.message, {
|
|
287
|
+
holder: { ingotId, status, sizeBytes },
|
|
288
|
+
details: err.details,
|
|
289
|
+
meta: err.meta,
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
|
|
242
293
|
export class MobileIngotsClient {
|
|
243
294
|
// Server-side finalization (encryption) of a just-uploaded ingot runs
|
|
244
295
|
// asynchronously and scales with file size, so a large video can sit in
|
|
@@ -276,19 +327,20 @@ export class MobileIngotsClient {
|
|
|
276
327
|
folder: options.folder,
|
|
277
328
|
folderId: options.folderId,
|
|
278
329
|
contentHmac: options.contentHmac,
|
|
330
|
+
onNameConflict: options.onNameConflict,
|
|
279
331
|
});
|
|
280
332
|
const created = init.created ?? true;
|
|
333
|
+
const name = init.name ?? options.name;
|
|
281
334
|
|
|
282
|
-
// The
|
|
283
|
-
//
|
|
284
|
-
//
|
|
285
|
-
options.onIngotCreated?.({ ingotId: init.ingotId, created });
|
|
335
|
+
// The placement is written but no Forge session exists yet: a throw from
|
|
336
|
+
// the hook stops the upload before a byte moves and reaches the caller
|
|
337
|
+
// untouched.
|
|
338
|
+
options.onIngotCreated?.({ ingotId: init.ingotId, created, name });
|
|
286
339
|
|
|
287
340
|
await this.tus.uploadFromUri({
|
|
288
341
|
fileUri: options.fileUri,
|
|
289
342
|
fileSize: options.fileSize,
|
|
290
|
-
filename:
|
|
291
|
-
contentType: options.contentType,
|
|
343
|
+
filename: name,
|
|
292
344
|
forgeUrl: init.forgeUrl,
|
|
293
345
|
onSessionCreated: session => options.onSessionCreated?.({ ...session, ingotId: init.ingotId, created }),
|
|
294
346
|
transport: options.transport,
|
|
@@ -303,7 +355,7 @@ export class MobileIngotsClient {
|
|
|
303
355
|
|
|
304
356
|
return {
|
|
305
357
|
ingot_id: init.ingotId,
|
|
306
|
-
name
|
|
358
|
+
name,
|
|
307
359
|
size_bytes: init.sizeBytes ?? options.fileSize,
|
|
308
360
|
storage_location: 's3',
|
|
309
361
|
// A server that does not report it minted the row (the upsert is the
|
|
@@ -322,7 +374,9 @@ export class MobileIngotsClient {
|
|
|
322
374
|
*
|
|
323
375
|
* A `TusUploadError` with `sessionLost` means Forge no longer has the
|
|
324
376
|
* session (expired or terminated); the caller drops it and starts over
|
|
325
|
-
* with `uploadFromUri`.
|
|
377
|
+
* with `uploadFromUri`. A session `uploadUrl` that is not an upload session
|
|
378
|
+
* on an allowed transfer host is refused (SparkVaultValidationError) before
|
|
379
|
+
* the ISTK or a byte is sent anywhere.
|
|
326
380
|
*/
|
|
327
381
|
async resumeUpload(options: ResumeUploadOptions): Promise<UploadResult> {
|
|
328
382
|
validateVaultId(options.vaultId);
|
|
@@ -335,7 +389,6 @@ export class MobileIngotsClient {
|
|
|
335
389
|
fileUri: options.fileUri,
|
|
336
390
|
fileSize: options.fileSize,
|
|
337
391
|
filename: options.name,
|
|
338
|
-
contentType: options.contentType,
|
|
339
392
|
resume: options.session,
|
|
340
393
|
transport: options.transport,
|
|
341
394
|
transferPolicy: options.transferPolicy,
|
|
@@ -362,6 +415,8 @@ export class MobileIngotsClient {
|
|
|
362
415
|
* longer has it. Lets a caller reconcile sessions on relaunch — a session
|
|
363
416
|
* at `offset === length` only needs its ingot verified, a lost one needs a
|
|
364
417
|
* fresh `uploadFromUri` — without spending an upload slot to find out.
|
|
418
|
+
* An `uploadUrl` that is not an upload session on an allowed transfer host
|
|
419
|
+
* is refused (SparkVaultValidationError) before the ISTK is sent anywhere.
|
|
365
420
|
*/
|
|
366
421
|
probeUploadSession(
|
|
367
422
|
session: { uploadUrl: string; istk: string }
|
|
@@ -375,6 +430,8 @@ export class MobileIngotsClient {
|
|
|
375
430
|
* the partial transfer's billing instead of waiting for expiry. Resolves
|
|
376
431
|
* once the session is gone, including when Forge no longer had it
|
|
377
432
|
* (404/410); any other failure rejects so the caller can retry later.
|
|
433
|
+
* An `uploadUrl` that is not an upload session on an allowed transfer host
|
|
434
|
+
* is refused (SparkVaultValidationError) before the ISTK is sent anywhere.
|
|
378
435
|
*/
|
|
379
436
|
terminateUploadSession(session: { uploadUrl: string; istk: string }): Promise<void> {
|
|
380
437
|
return this.tus.terminateSession(session);
|
|
@@ -402,11 +459,14 @@ export class MobileIngotsClient {
|
|
|
402
459
|
if (options.contentHmac) {
|
|
403
460
|
body.content_hmac = options.contentHmac;
|
|
404
461
|
}
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
462
|
+
if (options.onNameConflict) {
|
|
463
|
+
body.on_name_conflict = options.onNameConflict;
|
|
464
|
+
}
|
|
465
|
+
const response = await this.http
|
|
466
|
+
.post<IngotUploadInitResponse>(apiPath`/vaults/${options.vaultId}/ingots`, body, { vat: options.vat })
|
|
467
|
+
.catch((err: unknown) => {
|
|
468
|
+
throw toTypedCreateError(err);
|
|
469
|
+
});
|
|
410
470
|
|
|
411
471
|
if (!response.data.ingot_id || !response.data.forge_url) {
|
|
412
472
|
throw new SparkVaultMobileError('Server returned invalid response for ingot creation');
|
|
@@ -431,7 +491,7 @@ export class MobileIngotsClient {
|
|
|
431
491
|
}
|
|
432
492
|
|
|
433
493
|
const response = await this.http.put<IngotUploadInitResponse>(
|
|
434
|
-
`/vaults/${vaultId}/ingots/${ingotId}`,
|
|
494
|
+
apiPath`/vaults/${vaultId}/ingots/${ingotId}`,
|
|
435
495
|
{
|
|
436
496
|
name,
|
|
437
497
|
content_type: contentType,
|
|
@@ -449,7 +509,6 @@ export class MobileIngotsClient {
|
|
|
449
509
|
fileUri,
|
|
450
510
|
fileSize,
|
|
451
511
|
filename: name,
|
|
452
|
-
contentType,
|
|
453
512
|
forgeUrl: response.data.forge_url,
|
|
454
513
|
// A replace never mints an ingot: the row is the caller's existing one.
|
|
455
514
|
onSessionCreated: session => options.onSessionCreated?.({ ...session, ingotId, created: false }),
|
|
@@ -486,7 +545,7 @@ export class MobileIngotsClient {
|
|
|
486
545
|
): Promise<HashCheckResponse> {
|
|
487
546
|
validateVaultId(vaultId);
|
|
488
547
|
const response = await this.http.post<HashCheckResponse>(
|
|
489
|
-
`/vaults/${vaultId}/ingots/hash-check`,
|
|
548
|
+
apiPath`/vaults/${vaultId}/ingots/hash-check`,
|
|
490
549
|
{ hashes },
|
|
491
550
|
{ vat }
|
|
492
551
|
);
|
|
@@ -496,7 +555,7 @@ export class MobileIngotsClient {
|
|
|
496
555
|
async rename(vaultId: string, ingotId: string, vat: string, name: string): Promise<Ingot> {
|
|
497
556
|
validateVaultId(vaultId);
|
|
498
557
|
validateIngotId(ingotId);
|
|
499
|
-
const response = await this.http.patch<Ingot>(`/vaults/${vaultId}/ingots/${ingotId}`, { name }, { vat });
|
|
558
|
+
const response = await this.http.patch<Ingot>(apiPath`/vaults/${vaultId}/ingots/${ingotId}`, { name }, { vat });
|
|
500
559
|
return response.data;
|
|
501
560
|
}
|
|
502
561
|
|
|
@@ -506,44 +565,37 @@ export class MobileIngotsClient {
|
|
|
506
565
|
options?: { limit?: number; cursor?: string }
|
|
507
566
|
): Promise<ListIngotsResponse> {
|
|
508
567
|
validateVaultId(vaultId);
|
|
509
|
-
const
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
const query = params.toString();
|
|
514
|
-
const response = await this.http.get<ListIngotsResponse>(
|
|
515
|
-
query ? `/vaults/${vaultId}/ingots?${query}` : `/vaults/${vaultId}/ingots`,
|
|
516
|
-
{ vat }
|
|
517
|
-
);
|
|
568
|
+
const response = await this.http.get<ListIngotsResponse>(apiPath`/vaults/${vaultId}/ingots`, {
|
|
569
|
+
vat,
|
|
570
|
+
query: { limit: options?.limit || undefined, cursor: options?.cursor },
|
|
571
|
+
});
|
|
518
572
|
return response.data;
|
|
519
573
|
}
|
|
520
574
|
|
|
575
|
+
/**
|
|
576
|
+
* List a folder, or the vault root, whole: the folder, its breadcrumb, its
|
|
577
|
+
* direct subfolders and every ingot directly in it, in one response.
|
|
578
|
+
*/
|
|
521
579
|
async listContents(
|
|
522
580
|
vaultId: string,
|
|
523
581
|
vat: string,
|
|
524
582
|
options?: {
|
|
525
583
|
folder_id?: string | null;
|
|
526
|
-
|
|
527
|
-
limit?: number;
|
|
584
|
+
/** Ingot sort field (name | size | content_type | created | access_count | bandwidth); `created` by default */
|
|
528
585
|
sort_by?: string;
|
|
586
|
+
/** `desc` by default */
|
|
529
587
|
sort_order?: 'asc' | 'desc';
|
|
530
|
-
type?: string;
|
|
531
588
|
}
|
|
532
|
-
): Promise<
|
|
589
|
+
): Promise<FolderContentsResponse> {
|
|
533
590
|
validateVaultId(vaultId);
|
|
534
|
-
const
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
const query = params.toString();
|
|
543
|
-
const response = await this.http.get<ListIngotsResponse>(
|
|
544
|
-
query ? `/vaults/${vaultId}/contents?${query}` : `/vaults/${vaultId}/contents`,
|
|
545
|
-
{ vat }
|
|
546
|
-
);
|
|
591
|
+
const response = await this.http.get<FolderContentsResponse>(apiPath`/vaults/${vaultId}/contents`, {
|
|
592
|
+
vat,
|
|
593
|
+
query: {
|
|
594
|
+
folder_id: options?.folder_id,
|
|
595
|
+
sort_by: options?.sort_by,
|
|
596
|
+
sort_order: options?.sort_order,
|
|
597
|
+
},
|
|
598
|
+
});
|
|
547
599
|
return response.data;
|
|
548
600
|
}
|
|
549
601
|
|
|
@@ -562,35 +614,36 @@ export class MobileIngotsClient {
|
|
|
562
614
|
}
|
|
563
615
|
): Promise<ListIngotsResponse> {
|
|
564
616
|
validateVaultId(vaultId);
|
|
565
|
-
const
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
{ vat }
|
|
579
|
-
);
|
|
617
|
+
const response = await this.http.get<ListIngotsResponse>(apiPath`/vaults/${vaultId}/ingots/search`, {
|
|
618
|
+
vat,
|
|
619
|
+
query: {
|
|
620
|
+
q: options?.q,
|
|
621
|
+
file_type: options?.file_type === 'all' ? undefined : options?.file_type,
|
|
622
|
+
folder_id: options?.folder_id,
|
|
623
|
+
min_size: options?.min_size,
|
|
624
|
+
max_size: options?.max_size,
|
|
625
|
+
limit: options?.limit || undefined,
|
|
626
|
+
cursor: options?.cursor,
|
|
627
|
+
sort_order: options?.sort_order,
|
|
628
|
+
},
|
|
629
|
+
});
|
|
580
630
|
return response.data;
|
|
581
631
|
}
|
|
582
632
|
|
|
583
|
-
async get(vaultId: string, ingotId: string, vat: string): Promise<Ingot> {
|
|
633
|
+
async get(vaultId: string, ingotId: string, vat: string, options?: { consistent?: boolean }): Promise<Ingot> {
|
|
584
634
|
validateVaultId(vaultId);
|
|
585
635
|
validateIngotId(ingotId);
|
|
586
|
-
const response = await this.http.get<Ingot>(`/vaults/${vaultId}/ingots/${ingotId}`, {
|
|
636
|
+
const response = await this.http.get<Ingot>(apiPath`/vaults/${vaultId}/ingots/${ingotId}`, {
|
|
637
|
+
vat,
|
|
638
|
+
query: { consistent: options?.consistent ? 'true' : undefined },
|
|
639
|
+
});
|
|
587
640
|
return response.data;
|
|
588
641
|
}
|
|
589
642
|
|
|
590
643
|
async delete(vaultId: string, ingotId: string, vat: string): Promise<void> {
|
|
591
644
|
validateVaultId(vaultId);
|
|
592
645
|
validateIngotId(ingotId);
|
|
593
|
-
await this.http.delete(`/vaults/${vaultId}/ingots/${ingotId}`, { vat });
|
|
646
|
+
await this.http.delete(apiPath`/vaults/${vaultId}/ingots/${ingotId}`, { vat });
|
|
594
647
|
}
|
|
595
648
|
|
|
596
649
|
/**
|
|
@@ -605,8 +658,8 @@ export class MobileIngotsClient {
|
|
|
605
658
|
validateIngotId(ingotId);
|
|
606
659
|
|
|
607
660
|
const response = await this.http
|
|
608
|
-
.post<{ download_url?: string }>(
|
|
609
|
-
`/vaults/${vaultId}/ingots/${ingotId}/download`,
|
|
661
|
+
.post<{ download_url?: string; content_version?: string | null }>(
|
|
662
|
+
apiPath`/vaults/${vaultId}/ingots/${ingotId}/download`,
|
|
610
663
|
undefined,
|
|
611
664
|
{ vat }
|
|
612
665
|
)
|
|
@@ -620,7 +673,7 @@ export class MobileIngotsClient {
|
|
|
620
673
|
}
|
|
621
674
|
|
|
622
675
|
this.validateDownloadUrl(downloadUrl);
|
|
623
|
-
return { downloadUrl };
|
|
676
|
+
return { downloadUrl, contentVersion: response.data.content_version ?? null };
|
|
624
677
|
}
|
|
625
678
|
|
|
626
679
|
async downloadToFile(options: DownloadToFileOptions): Promise<DownloadToFileResult> {
|
|
@@ -666,13 +719,23 @@ export class MobileIngotsClient {
|
|
|
666
719
|
|
|
667
720
|
const status = result?.status;
|
|
668
721
|
if (result?.uri && (status === undefined || (status >= 200 && status < 300))) {
|
|
669
|
-
|
|
722
|
+
const verified = await this.verifyDownloadedFile(
|
|
670
723
|
fileDownloader,
|
|
671
724
|
result.uri,
|
|
672
725
|
fileUri,
|
|
673
726
|
result.sizeBytes,
|
|
674
727
|
expectedSizeBytes
|
|
675
728
|
);
|
|
729
|
+
// Bind cached metadata to the actual stream, never the earlier URL response.
|
|
730
|
+
const contentVersion = Object.entries(result.headers ?? {})
|
|
731
|
+
.find(([name]) => name.toLowerCase() === 'x-content-version')?.[1] || null;
|
|
732
|
+
if (options.expectedContentVersion && contentVersion && options.expectedContentVersion !== contentVersion) {
|
|
733
|
+
await this.deleteFileIfPresent(fileDownloader, verified.filePath);
|
|
734
|
+
throw new SparkVaultMobileError('File changed while downloading. Please try again.', {
|
|
735
|
+
code: 'content_version_changed', statusCode: 409,
|
|
736
|
+
});
|
|
737
|
+
}
|
|
738
|
+
return { ...verified, contentVersion };
|
|
676
739
|
}
|
|
677
740
|
|
|
678
741
|
await this.deleteFileIfPresent(fileDownloader, fileUri);
|
|
@@ -705,7 +768,7 @@ export class MobileIngotsClient {
|
|
|
705
768
|
validateVaultId(vaultId);
|
|
706
769
|
validateIngotId(ingotId);
|
|
707
770
|
const response = await this.http.get<IngotSharingConfig>(
|
|
708
|
-
`/vaults/${vaultId}/ingots/${ingotId}/sharing`,
|
|
771
|
+
apiPath`/vaults/${vaultId}/ingots/${ingotId}/sharing`,
|
|
709
772
|
{ vat }
|
|
710
773
|
);
|
|
711
774
|
return response.data;
|
|
@@ -723,7 +786,7 @@ export class MobileIngotsClient {
|
|
|
723
786
|
validateVaultId(vaultId);
|
|
724
787
|
validateIngotId(ingotId);
|
|
725
788
|
const response = await this.http.put<IngotSharingConfig>(
|
|
726
|
-
`/vaults/${vaultId}/ingots/${ingotId}/sharing`,
|
|
789
|
+
apiPath`/vaults/${vaultId}/ingots/${ingotId}/sharing`,
|
|
727
790
|
config,
|
|
728
791
|
{ vat }
|
|
729
792
|
);
|
|
@@ -740,7 +803,7 @@ export class MobileIngotsClient {
|
|
|
740
803
|
validateVaultId(vaultId);
|
|
741
804
|
validateIngotId(ingotId);
|
|
742
805
|
const response = await this.http.post<IngotInvite>(
|
|
743
|
-
`/vaults/${vaultId}/ingots/${ingotId}/sharing/invite`,
|
|
806
|
+
apiPath`/vaults/${vaultId}/ingots/${ingotId}/sharing/invite`,
|
|
744
807
|
{ identity, expires_in_seconds: options.expiresInSeconds },
|
|
745
808
|
{ vat }
|
|
746
809
|
);
|
|
@@ -750,7 +813,8 @@ export class MobileIngotsClient {
|
|
|
750
813
|
async revokeInvite(vaultId: string, ingotId: string, vat: string, inviteId: string): Promise<void> {
|
|
751
814
|
validateVaultId(vaultId);
|
|
752
815
|
validateIngotId(ingotId);
|
|
753
|
-
|
|
816
|
+
validateResourceId(inviteId, 'invite ID', 'inviteId');
|
|
817
|
+
await this.http.delete(apiPath`/vaults/${vaultId}/ingots/${ingotId}/sharing/invite/${inviteId}`, { vat });
|
|
754
818
|
}
|
|
755
819
|
|
|
756
820
|
async getAccessLogs(
|
|
@@ -761,22 +825,25 @@ export class MobileIngotsClient {
|
|
|
761
825
|
): Promise<{ logs: IngotAccessLog[]; next_cursor?: string }> {
|
|
762
826
|
validateVaultId(vaultId);
|
|
763
827
|
validateIngotId(ingotId);
|
|
764
|
-
const params = new URLSearchParams();
|
|
765
|
-
if (options?.limit) params.append('limit', options.limit.toString());
|
|
766
|
-
if (options?.cursor) params.append('cursor', options.cursor);
|
|
767
|
-
|
|
768
|
-
const query = params.toString();
|
|
769
828
|
const response = await this.http.get<IngotAuditLogsResponse>(
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
829
|
+
apiPath`/vaults/${vaultId}/ingots/${ingotId}/audit-logs`,
|
|
830
|
+
{
|
|
831
|
+
vat,
|
|
832
|
+
query: {
|
|
833
|
+
limit: options?.limit || undefined,
|
|
834
|
+
cursor: options?.cursor,
|
|
835
|
+
event_types: INGOT_ACCESS_EVENT_TYPES.join(','),
|
|
836
|
+
},
|
|
837
|
+
}
|
|
774
838
|
);
|
|
775
|
-
const
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
839
|
+
const logs: IngotAccessLog[] = [];
|
|
840
|
+
for (const entry of response.data.entries ?? []) {
|
|
841
|
+
const access = readIngotAccess(entry);
|
|
842
|
+
if (access) {
|
|
843
|
+
logs.push({ log_id: String(access.timestamp), ...access });
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
return { logs, next_cursor: response.data.next_cursor };
|
|
780
847
|
}
|
|
781
848
|
|
|
782
849
|
async getAccessStats(vaultId: string, ingotId: string, vat: string): Promise<IngotAccessStats> {
|
|
@@ -803,8 +870,7 @@ export class MobileIngotsClient {
|
|
|
803
870
|
*
|
|
804
871
|
* The bytes are already fully uploaded and the ingot record exists before
|
|
805
872
|
* this runs, so this NEVER re-creates or re-uploads anything - it only polls
|
|
806
|
-
* status. A `
|
|
807
|
-
* caller should surface). A still-`processing` ingot after the poll budget
|
|
873
|
+
* status. A still-`uploading` ingot after the poll budget
|
|
808
874
|
* is returned as success, not thrown: the transport completed and the ingot
|
|
809
875
|
* exists, so treating it as a failure would make the caller re-run the whole
|
|
810
876
|
* upload and mint a DUPLICATE ingot (re-streaming the entire file and
|
|
@@ -821,10 +887,6 @@ export class MobileIngotsClient {
|
|
|
821
887
|
let ingot = await this.get(vaultId, ingotId, vat);
|
|
822
888
|
|
|
823
889
|
while (ingot.status !== 'active') {
|
|
824
|
-
if (ingot.status === 'failed') {
|
|
825
|
-
throw new SparkVaultMobileError('Upload failed: server encryption error');
|
|
826
|
-
}
|
|
827
|
-
|
|
828
890
|
const remaining = deadline - Date.now();
|
|
829
891
|
if (remaining <= 0) {
|
|
830
892
|
debug?.log(
|
|
@@ -842,28 +904,6 @@ export class MobileIngotsClient {
|
|
|
842
904
|
return ingot;
|
|
843
905
|
}
|
|
844
906
|
|
|
845
|
-
private toAccessLog(entry: IngotAuditLogEntry): IngotAccessLog {
|
|
846
|
-
const eventType = entry.event_type ?? entry.action ?? '';
|
|
847
|
-
const action = eventType.includes('download') ? 'download' : 'view';
|
|
848
|
-
const actor = entry.actor;
|
|
849
|
-
return {
|
|
850
|
-
log_id: entry.log_id ?? entry.event_id ?? entry.id ?? String(entry.timestamp ?? entry.created_at ?? ''),
|
|
851
|
-
action,
|
|
852
|
-
identity:
|
|
853
|
-
actor?.email ??
|
|
854
|
-
entry.actor_email ??
|
|
855
|
-
entry.identity ??
|
|
856
|
-
actor?.id ??
|
|
857
|
-
actor?.user_id ??
|
|
858
|
-
actor?.api_key_id ??
|
|
859
|
-
entry.actor_id ??
|
|
860
|
-
'Unknown',
|
|
861
|
-
ip_address: entry.ip_address ?? '',
|
|
862
|
-
user_agent: entry.user_agent ?? '',
|
|
863
|
-
timestamp: entry.timestamp ?? entry.created_at ?? 0,
|
|
864
|
-
};
|
|
865
|
-
}
|
|
866
|
-
|
|
867
907
|
private async verifyDownloadedFile(
|
|
868
908
|
fileDownloader: MobileFileDownloader,
|
|
869
909
|
preferredUri: string,
|
|
@@ -939,26 +979,14 @@ export class MobileIngotsClient {
|
|
|
939
979
|
});
|
|
940
980
|
}
|
|
941
981
|
|
|
982
|
+
/** Refuse a download link that is not a Forge decrypt URL on a trusted host (forge-url's rule). */
|
|
942
983
|
private validateDownloadUrl(downloadUrl: string): void {
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
}
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
if (url.protocol !== 'https:') {
|
|
951
|
-
throw new SparkVaultValidationError('Invalid download URL from server', {
|
|
952
|
-
reason: 'non_https_download_url',
|
|
953
|
-
});
|
|
954
|
-
}
|
|
955
|
-
|
|
956
|
-
const allowed = this.config.allowedDownloadHostPatterns.some(pattern => pattern.test(url.hostname));
|
|
957
|
-
if (!allowed) {
|
|
958
|
-
throw new SparkVaultValidationError('Invalid download URL from server', {
|
|
959
|
-
reason: 'download_host_not_allowed',
|
|
960
|
-
hostname: url.hostname,
|
|
961
|
-
});
|
|
984
|
+
const read = readForgeTransferUrl(downloadUrl, {
|
|
985
|
+
operation: 'decrypt',
|
|
986
|
+
isAllowedHost: allowsHost(this.config.allowedDownloadHostPatterns),
|
|
987
|
+
});
|
|
988
|
+
if (!read.ok) {
|
|
989
|
+
throw new SparkVaultValidationError('Invalid download URL from server', { reason: read.reason });
|
|
962
990
|
}
|
|
963
991
|
}
|
|
964
992
|
|