@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.
Files changed (148) hide show
  1. package/README.md +5 -5
  2. package/dist/account.d.ts +4 -3
  3. package/dist/account.js +16 -11
  4. package/dist/account.js.map +1 -1
  5. package/dist/app-sdk/email-address.d.ts +37 -0
  6. package/dist/app-sdk/email-address.js +91 -0
  7. package/dist/app-sdk/email-address.js.map +1 -0
  8. package/dist/app-sdk/encoding.d.ts +64 -0
  9. package/dist/app-sdk/encoding.js +245 -0
  10. package/dist/app-sdk/encoding.js.map +1 -0
  11. package/dist/app-sdk/entropy-contract.d.ts +33 -0
  12. package/dist/app-sdk/entropy-contract.js +51 -0
  13. package/dist/app-sdk/entropy-contract.js.map +1 -0
  14. package/dist/app-sdk/filename.d.ts +93 -0
  15. package/dist/app-sdk/filename.js +161 -0
  16. package/dist/app-sdk/filename.js.map +1 -0
  17. package/dist/app-sdk/forge-url.d.ts +107 -0
  18. package/dist/app-sdk/forge-url.js +222 -0
  19. package/dist/app-sdk/forge-url.js.map +1 -0
  20. package/dist/app-sdk/ids.d.ts +48 -0
  21. package/dist/app-sdk/ids.js +65 -0
  22. package/dist/app-sdk/ids.js.map +1 -0
  23. package/dist/app-sdk/ingot-access-log.d.ts +29 -0
  24. package/dist/app-sdk/ingot-access-log.js +49 -0
  25. package/dist/app-sdk/ingot-access-log.js.map +1 -0
  26. package/dist/app-sdk/phone.d.ts +37 -0
  27. package/dist/app-sdk/phone.js +134 -0
  28. package/dist/app-sdk/phone.js.map +1 -0
  29. package/dist/app-sdk/platform-domains.d.ts +41 -0
  30. package/dist/app-sdk/platform-domains.js +82 -0
  31. package/dist/app-sdk/platform-domains.js.map +1 -0
  32. package/dist/app-sdk/request-path.d.ts +49 -0
  33. package/dist/app-sdk/request-path.js +81 -0
  34. package/dist/app-sdk/request-path.js.map +1 -0
  35. package/dist/app-sdk/transient.d.ts +23 -0
  36. package/dist/app-sdk/transient.js +29 -0
  37. package/dist/app-sdk/transient.js.map +1 -0
  38. package/dist/app-sdk/vat-refusal.d.ts +66 -0
  39. package/dist/app-sdk/vat-refusal.js +69 -0
  40. package/dist/app-sdk/vat-refusal.js.map +1 -0
  41. package/dist/billing.d.ts +9 -17
  42. package/dist/billing.js +14 -17
  43. package/dist/billing.js.map +1 -1
  44. package/dist/config.d.ts +7 -1
  45. package/dist/config.js +10 -17
  46. package/dist/config.js.map +1 -1
  47. package/dist/encoding.d.ts +30 -4
  48. package/dist/encoding.js +89 -83
  49. package/dist/encoding.js.map +1 -1
  50. package/dist/entropy.d.ts +25 -4
  51. package/dist/entropy.js +62 -15
  52. package/dist/entropy.js.map +1 -1
  53. package/dist/entropy.test-d.d.ts +40 -0
  54. package/dist/entropy.test-d.js +12 -0
  55. package/dist/entropy.test-d.js.map +1 -0
  56. package/dist/errors.d.ts +67 -17
  57. package/dist/errors.js +70 -27
  58. package/dist/errors.js.map +1 -1
  59. package/dist/folders.js +8 -7
  60. package/dist/folders.js.map +1 -1
  61. package/dist/http.d.ts +57 -15
  62. package/dist/http.js +125 -138
  63. package/dist/http.js.map +1 -1
  64. package/dist/identity-dialog.js +4 -30
  65. package/dist/identity-dialog.js.map +1 -1
  66. package/dist/identity-input.d.ts +39 -0
  67. package/dist/identity-input.js +59 -0
  68. package/dist/identity-input.js.map +1 -0
  69. package/dist/index.d.ts +6 -5
  70. package/dist/index.js +4 -3
  71. package/dist/index.js.map +1 -1
  72. package/dist/ingots.d.ts +58 -19
  73. package/dist/ingots.js +145 -125
  74. package/dist/ingots.js.map +1 -1
  75. package/dist/mutex.d.ts +1 -2
  76. package/dist/mutex.js +0 -6
  77. package/dist/mutex.js.map +1 -1
  78. package/dist/notify.d.ts +7 -2
  79. package/dist/notify.js +12 -15
  80. package/dist/notify.js.map +1 -1
  81. package/dist/notify.test-d.d.ts +3 -2
  82. package/dist/notify.test-d.js +1 -1
  83. package/dist/products/identity.d.ts +5 -0
  84. package/dist/products/identity.js +40 -35
  85. package/dist/products/identity.js.map +1 -1
  86. package/dist/request-path.d.ts +32 -0
  87. package/dist/request-path.js +35 -0
  88. package/dist/request-path.js.map +1 -0
  89. package/dist/sparks.js +16 -15
  90. package/dist/sparks.js.map +1 -1
  91. package/dist/tus.d.ts +19 -2
  92. package/dist/tus.js +86 -35
  93. package/dist/tus.js.map +1 -1
  94. package/dist/types.d.ts +169 -15
  95. package/dist/users.d.ts +12 -5
  96. package/dist/users.js +27 -19
  97. package/dist/users.js.map +1 -1
  98. package/dist/validation.d.ts +20 -5
  99. package/dist/validation.js +29 -27
  100. package/dist/validation.js.map +1 -1
  101. package/dist/vaults.d.ts +34 -83
  102. package/dist/vaults.js +64 -44
  103. package/dist/vaults.js.map +1 -1
  104. package/dist/vaults.test-d.d.ts +22 -0
  105. package/dist/vaults.test-d.js +14 -0
  106. package/dist/vaults.test-d.js.map +1 -0
  107. package/package.json +4 -2
  108. package/src/account.ts +16 -11
  109. package/src/app-sdk/email-address.js +94 -0
  110. package/src/app-sdk/encoding.js +250 -0
  111. package/src/app-sdk/entropy-contract.js +56 -0
  112. package/src/app-sdk/filename.js +174 -0
  113. package/src/app-sdk/forge-url.js +224 -0
  114. package/src/app-sdk/ids.js +71 -0
  115. package/src/app-sdk/ingot-access-log.js +51 -0
  116. package/src/app-sdk/phone.js +139 -0
  117. package/src/app-sdk/platform-domains.js +99 -0
  118. package/src/app-sdk/request-path.js +81 -0
  119. package/src/app-sdk/transient.js +30 -0
  120. package/src/app-sdk/vat-refusal.js +74 -0
  121. package/src/billing.ts +16 -25
  122. package/src/config.ts +16 -23
  123. package/src/encoding.ts +91 -103
  124. package/src/entropy.test-d.ts +47 -0
  125. package/src/entropy.ts +86 -20
  126. package/src/errors.ts +93 -33
  127. package/src/folders.ts +8 -7
  128. package/src/http.ts +145 -150
  129. package/src/identity-dialog.tsx +4 -37
  130. package/src/identity-input.ts +70 -0
  131. package/src/index.ts +11 -3
  132. package/src/ingots.ts +191 -163
  133. package/src/mutex.ts +1 -8
  134. package/src/notify.test-d.ts +3 -1
  135. package/src/notify.ts +19 -17
  136. package/src/products/identity.ts +45 -39
  137. package/src/request-path.ts +45 -0
  138. package/src/sparks.ts +16 -13
  139. package/src/tus.ts +90 -37
  140. package/src/types.ts +167 -15
  141. package/src/users.ts +27 -17
  142. package/src/validation.ts +30 -24
  143. package/src/vaults.test-d.ts +29 -0
  144. package/src/vaults.ts +87 -153
  145. package/dist/platform-domains.generated.d.ts +0 -2
  146. package/dist/platform-domains.generated.js +0 -16
  147. package/dist/platform-domains.generated.js.map +0 -1
  148. 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 API has minted (or, via its same-name upsert, handed
60
- * back) the ingot and before any Forge session exists. `created: false`
61
- * means the bytes about to be sent will REPLACE an existing ingot's content
62
- * once they finalize; a caller that must never overwrite throws here, and
63
- * the throw propagates as-is with nothing sent and no session to clean up.
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
- export interface ReplaceFromUriOptions extends UploadFromUriOptions {
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 handed back an existing
171
- * row via its same-name upsert (false). Absent when the server did not say.
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
- interface IngotAuditLogEntry {
196
- id?: string;
197
- log_id?: string;
198
- event_id?: string;
199
- event_type?: string;
200
- action?: string;
201
- actor?: {
202
- email?: string;
203
- id?: string;
204
- user_id?: string;
205
- api_key_id?: string;
206
- };
207
- actor_email?: string;
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?: IngotAuditLogEntry[];
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 ingot row exists but no Forge session does: a caller that refuses
283
- // to replace an existing ingot (created: false) aborts here, before a
284
- // byte moves, and its error reaches it untouched.
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: options.name,
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: init.name ?? options.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
- const response = await this.http.post<IngotUploadInitResponse>(
406
- `/vaults/${options.vaultId}/ingots`,
407
- body,
408
- { vat: options.vat }
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 params = new URLSearchParams();
510
- if (options?.limit) params.append('limit', options.limit.toString());
511
- if (options?.cursor) params.append('cursor', options.cursor);
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
- cursor?: string;
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<ListIngotsResponse> {
589
+ ): Promise<FolderContentsResponse> {
533
590
  validateVaultId(vaultId);
534
- const params = new URLSearchParams();
535
- if (options?.folder_id) params.append('folder_id', options.folder_id);
536
- if (options?.cursor) params.append('cursor', options.cursor);
537
- if (options?.limit) params.append('limit', options.limit.toString());
538
- if (options?.sort_by) params.append('sort_by', options.sort_by);
539
- if (options?.sort_order) params.append('sort_order', options.sort_order);
540
- if (options?.type && options.type !== 'all') params.append('type', options.type);
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 params = new URLSearchParams();
566
- if (options?.q) params.append('q', options.q);
567
- if (options?.file_type && options.file_type !== 'all') params.append('file_type', options.file_type);
568
- if (options?.folder_id) params.append('folder_id', options.folder_id);
569
- if (options?.min_size !== undefined) params.append('min_size', options.min_size.toString());
570
- if (options?.max_size !== undefined) params.append('max_size', options.max_size.toString());
571
- if (options?.limit) params.append('limit', options.limit.toString());
572
- if (options?.cursor) params.append('cursor', options.cursor);
573
- if (options?.sort_order) params.append('sort_order', options.sort_order);
574
-
575
- const query = params.toString();
576
- const response = await this.http.get<ListIngotsResponse>(
577
- query ? `/vaults/${vaultId}/ingots/search?${query}` : `/vaults/${vaultId}/ingots/search`,
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}`, { vat });
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
- return await this.verifyDownloadedFile(
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
- await this.http.delete(`/vaults/${vaultId}/ingots/${ingotId}/sharing/invite/${inviteId}`, { vat });
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
- query
771
- ? `/vaults/${vaultId}/ingots/${ingotId}/audit-logs?${query}`
772
- : `/vaults/${vaultId}/ingots/${ingotId}/audit-logs`,
773
- { vat }
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 entries = response.data.entries ?? [];
776
- return {
777
- logs: entries.map(entry => this.toAccessLog(entry)),
778
- next_cursor: response.data.next_cursor,
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 `failed` status throws (a genuine server encryption error the
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
- let url: URL;
944
- try {
945
- url = new URL(downloadUrl);
946
- } catch {
947
- throw new SparkVaultValidationError('Invalid download URL from server');
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