@sparkvault/sdk-mobile 6.0.2 → 7.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (156) 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/transfer-retry.d.ts +298 -0
  36. package/dist/app-sdk/transfer-retry.js +535 -0
  37. package/dist/app-sdk/transfer-retry.js.map +1 -0
  38. package/dist/app-sdk/transient.d.ts +23 -0
  39. package/dist/app-sdk/transient.js +29 -0
  40. package/dist/app-sdk/transient.js.map +1 -0
  41. package/dist/app-sdk/upload-publication.d.ts +81 -0
  42. package/dist/app-sdk/upload-publication.js +93 -0
  43. package/dist/app-sdk/upload-publication.js.map +1 -0
  44. package/dist/app-sdk/vat-refusal.d.ts +66 -0
  45. package/dist/app-sdk/vat-refusal.js +69 -0
  46. package/dist/app-sdk/vat-refusal.js.map +1 -0
  47. package/dist/billing.d.ts +9 -17
  48. package/dist/billing.js +14 -17
  49. package/dist/billing.js.map +1 -1
  50. package/dist/config.d.ts +33 -1
  51. package/dist/config.js +23 -17
  52. package/dist/config.js.map +1 -1
  53. package/dist/encoding.d.ts +30 -4
  54. package/dist/encoding.js +89 -83
  55. package/dist/encoding.js.map +1 -1
  56. package/dist/entropy.d.ts +25 -4
  57. package/dist/entropy.js +62 -15
  58. package/dist/entropy.js.map +1 -1
  59. package/dist/entropy.test-d.d.ts +40 -0
  60. package/dist/entropy.test-d.js +12 -0
  61. package/dist/entropy.test-d.js.map +1 -0
  62. package/dist/errors.d.ts +105 -21
  63. package/dist/errors.js +74 -28
  64. package/dist/errors.js.map +1 -1
  65. package/dist/folders.js +8 -7
  66. package/dist/folders.js.map +1 -1
  67. package/dist/http.d.ts +57 -15
  68. package/dist/http.js +125 -138
  69. package/dist/http.js.map +1 -1
  70. package/dist/identity-dialog.js +4 -30
  71. package/dist/identity-dialog.js.map +1 -1
  72. package/dist/identity-input.d.ts +39 -0
  73. package/dist/identity-input.js +59 -0
  74. package/dist/identity-input.js.map +1 -0
  75. package/dist/index.d.ts +8 -7
  76. package/dist/index.js +5 -4
  77. package/dist/index.js.map +1 -1
  78. package/dist/ingots.d.ts +77 -32
  79. package/dist/ingots.js +152 -136
  80. package/dist/ingots.js.map +1 -1
  81. package/dist/mutex.d.ts +1 -2
  82. package/dist/mutex.js +0 -6
  83. package/dist/mutex.js.map +1 -1
  84. package/dist/notify.d.ts +7 -2
  85. package/dist/notify.js +12 -15
  86. package/dist/notify.js.map +1 -1
  87. package/dist/notify.test-d.d.ts +3 -2
  88. package/dist/notify.test-d.js +1 -1
  89. package/dist/products/identity.d.ts +5 -0
  90. package/dist/products/identity.js +40 -35
  91. package/dist/products/identity.js.map +1 -1
  92. package/dist/request-path.d.ts +32 -0
  93. package/dist/request-path.js +35 -0
  94. package/dist/request-path.js.map +1 -0
  95. package/dist/sparks.js +16 -15
  96. package/dist/sparks.js.map +1 -1
  97. package/dist/tus.d.ts +198 -16
  98. package/dist/tus.js +816 -327
  99. package/dist/tus.js.map +1 -1
  100. package/dist/types.d.ts +183 -23
  101. package/dist/users.d.ts +12 -5
  102. package/dist/users.js +27 -19
  103. package/dist/users.js.map +1 -1
  104. package/dist/validation.d.ts +20 -5
  105. package/dist/validation.js +29 -27
  106. package/dist/validation.js.map +1 -1
  107. package/dist/vaults.d.ts +34 -83
  108. package/dist/vaults.js +64 -44
  109. package/dist/vaults.js.map +1 -1
  110. package/dist/vaults.test-d.d.ts +22 -0
  111. package/dist/vaults.test-d.js +14 -0
  112. package/dist/vaults.test-d.js.map +1 -0
  113. package/package.json +4 -2
  114. package/src/account.ts +16 -11
  115. package/src/app-sdk/email-address.js +94 -0
  116. package/src/app-sdk/encoding.js +250 -0
  117. package/src/app-sdk/entropy-contract.js +56 -0
  118. package/src/app-sdk/filename.js +174 -0
  119. package/src/app-sdk/forge-url.js +224 -0
  120. package/src/app-sdk/ids.js +71 -0
  121. package/src/app-sdk/ingot-access-log.js +51 -0
  122. package/src/app-sdk/phone.js +139 -0
  123. package/src/app-sdk/platform-domains.js +99 -0
  124. package/src/app-sdk/request-path.js +81 -0
  125. package/src/app-sdk/transfer-retry.js +563 -0
  126. package/src/app-sdk/transient.js +30 -0
  127. package/src/app-sdk/upload-publication.js +102 -0
  128. package/src/app-sdk/vat-refusal.js +74 -0
  129. package/src/billing.ts +16 -25
  130. package/src/config.ts +56 -23
  131. package/src/encoding.ts +91 -103
  132. package/src/entropy.test-d.ts +47 -0
  133. package/src/entropy.ts +86 -20
  134. package/src/errors.ts +135 -38
  135. package/src/folders.ts +8 -7
  136. package/src/http.ts +145 -150
  137. package/src/identity-dialog.tsx +4 -37
  138. package/src/identity-input.ts +70 -0
  139. package/src/index.ts +13 -3
  140. package/src/ingots.ts +225 -180
  141. package/src/mutex.ts +1 -8
  142. package/src/notify.test-d.ts +3 -1
  143. package/src/notify.ts +19 -17
  144. package/src/products/identity.ts +45 -39
  145. package/src/request-path.ts +45 -0
  146. package/src/sparks.ts +16 -13
  147. package/src/tus.ts +1054 -408
  148. package/src/types.ts +181 -23
  149. package/src/users.ts +27 -17
  150. package/src/validation.ts +30 -24
  151. package/src/vaults.test-d.ts +29 -0
  152. package/src/vaults.ts +87 -153
  153. package/dist/platform-domains.generated.d.ts +0 -2
  154. package/dist/platform-domains.generated.js +0 -16
  155. package/dist/platform-domains.generated.js.map +0 -1
  156. package/src/platform-domains.generated.ts +0 -17
package/src/ingots.ts CHANGED
@@ -1,12 +1,16 @@
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,
10
+ TusUploadError,
7
11
  } from './errors.js';
8
12
  import type { MobileHttpClient } from './http.js';
9
- import { MobileTusUploader } from './tus.js';
13
+ import { allowsHost, MobileTusUploader, type TusSessionProbe } from './tus.js';
10
14
  import type {
11
15
  DebugLogger,
12
16
  HashCheckResponse,
@@ -16,6 +20,7 @@ import type {
16
20
  IngotInvite,
17
21
  IngotSharingConfig,
18
22
  ListIngotsResponse,
23
+ FolderContentsResponse,
19
24
  IngotUploadSession,
20
25
  MobileFileDownloader,
21
26
  UploadAbortBehavior,
@@ -24,7 +29,26 @@ import type {
24
29
  UploadTransferPolicy,
25
30
  UploadTransport,
26
31
  } from './types.js';
27
- import { validateIngotId, validateVaultId } from './validation.js';
32
+ import { validateIngotId, validateResourceId, validateVaultId } from './validation.js';
33
+ import { apiPath } from './request-path.js';
34
+
35
+ /**
36
+ * What the server does when a create's name is already held in the target
37
+ * folder (case-insensitive). The server decides on the holder it read before
38
+ * writing anything, so a caller that must not replace content says so here:
39
+ * - `upsert` (the server default): the upload replaces the holder's content.
40
+ * - `complete_pending`: a holder whose own upload never finalized is completed
41
+ * (`created: false`); a published holder is refused with
42
+ * `IngotNameHeldError`, untouched.
43
+ * - `refuse`: any holder, its upload finished or not, is refused with
44
+ * `IngotNameHeldError`, untouched; the create only ever mints.
45
+ * - `new_name`: the holder is never touched; the ingot is minted under the
46
+ * first free numbered name the server picks ("report (2).pdf"), returned
47
+ * as the result's `name`.
48
+ */
49
+ export const NAME_CONFLICT_POLICIES = ['upsert', 'complete_pending', 'refuse', 'new_name'] as const;
50
+
51
+ export type NameConflictPolicy = (typeof NAME_CONFLICT_POLICIES)[number];
28
52
 
29
53
  export interface UploadOptions {
30
54
  vaultId: string;
@@ -55,20 +79,31 @@ export interface UploadOptions {
55
79
  export interface UploadFromUriOptions extends UploadOptions {
56
80
  fileUri: string;
57
81
  fileSize: number;
82
+ /** What a name already held in the target folder does; omitted means the server's `upsert`. */
83
+ onNameConflict?: NameConflictPolicy;
58
84
  /**
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.
85
+ * Invoked once the server has placed the upload and before any Forge
86
+ * session exists: `created` is true when it minted the ingot and false when
87
+ * it staged the upload onto the row holding the name (as `onNameConflict`
88
+ * allowed), and `name` is the name the ingot is stored under (the server's
89
+ * numbered name under `new_name`). The placement has already been written
90
+ * by then, so this hook observes; a caller that must not replace content
91
+ * says so through `onNameConflict`. A throw here still stops the upload
92
+ * before a byte moves and propagates as-is.
64
93
  */
65
- onIngotCreated?: (ingot: { ingotId: string; created: boolean }) => void;
94
+ onIngotCreated?: (ingot: { ingotId: string; created: boolean; name: string }) => void;
66
95
  /**
67
96
  * Invoked once the Forge tus session exists and before the first byte is
68
97
  * sent, so the caller can persist it and `resumeUpload` after a failed
69
98
  * attempt or a relaunch instead of re-sending bytes Forge already holds.
70
99
  */
71
100
  onSessionCreated?: (session: IngotUploadSession) => void;
101
+ /**
102
+ * Invoked whenever the session's expiry changes (see
103
+ * `TusUploadFromUriOptions.onSessionExpiry`): a caller that persists the
104
+ * session keeps its `expiresAt` current with it.
105
+ */
106
+ onSessionExpiry?: (expiresAt: number | null) => void;
72
107
  /** See `UploadTransport`; default `foreground`. */
73
108
  transport?: UploadTransport;
74
109
  /** Network policy for the OS transfer engine; see `UploadTransferPolicy`. Default: both allowed. */
@@ -86,7 +121,8 @@ export interface UploadFromUriOptions extends UploadOptions {
86
121
  debug?: DebugLogger;
87
122
  }
88
123
 
89
- export interface ReplaceFromUriOptions extends UploadFromUriOptions {
124
+ /** A replace addresses its ingot by id: no name is placed, so the create-only hooks do not apply. */
125
+ export interface ReplaceFromUriOptions extends Omit<UploadFromUriOptions, 'onNameConflict' | 'onIngotCreated'> {
90
126
  ingotId: string;
91
127
  }
92
128
 
@@ -98,9 +134,14 @@ export interface ResumeUploadOptions {
98
134
  fileUri: string;
99
135
  fileSize: number;
100
136
  name: string;
101
- contentType: string;
102
- /** The persisted tus session handed to `onSessionCreated`. */
103
- session: { uploadUrl: string; istk: string };
137
+ /**
138
+ * The persisted tus session handed to `onSessionCreated`, with the expiry
139
+ * the caller keeps for it (`onSessionExpiry`): a session past it is
140
+ * refused as `sessionLost` before any request.
141
+ */
142
+ session: { uploadUrl: string; istk: string; expiresAt?: number | null };
143
+ /** See `UploadFromUriOptions.onSessionExpiry`. */
144
+ onSessionExpiry?: (expiresAt: number | null) => void;
104
145
  /** See `UploadTransport`; default `foreground`. */
105
146
  transport?: UploadTransport;
106
147
  /** Network policy for the OS transfer engine; see `UploadTransferPolicy`. Default: both allowed. */
@@ -161,17 +202,20 @@ export interface CreateUploadOptions {
161
202
  folderId?: string;
162
203
  /** Vault-keyed plaintext HMAC (43-char base64url); see UploadOptions.contentHmac. */
163
204
  contentHmac?: string;
205
+ /** What a name already held in the target folder does; omitted means the server's `upsert`. */
206
+ onNameConflict?: NameConflictPolicy;
164
207
  }
165
208
 
166
209
  export interface CreateUploadResult {
167
210
  ingotId: string;
168
211
  /** Forge tus endpoint plus upload-session token (ISTK) for the resumable upload. */
169
212
  forgeUrl: string;
213
+ /** The name the ingot is stored under: the server's numbered name under `new_name`. */
170
214
  name?: string;
171
215
  sizeBytes?: number;
172
216
  /**
173
- * Whether the server minted this ingot (true) or handed back an existing
174
- * row via its same-name upsert (false). Absent when the server did not say.
217
+ * Whether the server minted this ingot (true) or staged the upload onto
218
+ * the row already holding the name (false). Absent when the server did not say.
175
219
  */
176
220
  created?: boolean;
177
221
  }
@@ -196,29 +240,23 @@ interface IngotUploadInitResponse {
196
240
  created?: boolean;
197
241
  }
198
242
 
199
- interface IngotAuditLogEntry {
200
- id?: string;
201
- log_id?: string;
202
- event_id?: string;
203
- event_type?: string;
204
- action?: string;
205
- actor?: {
206
- email?: string;
207
- id?: string;
208
- user_id?: string;
209
- api_key_id?: string;
210
- };
211
- actor_email?: string;
212
- actor_id?: string;
213
- identity?: string;
214
- ip_address?: string;
215
- user_agent?: string;
216
- timestamp?: number;
217
- created_at?: number;
243
+ /** An ingot audit entry in the server's public shape. */
244
+ interface IngotAuditEntry {
245
+ timestamp: number;
246
+ event_type: string;
247
+ actor_id?: string | null;
248
+ actor_type?: string;
249
+ actor_display?: string;
250
+ ip_address?: string | null;
251
+ user_agent?: string | null;
252
+ metadata?: { identity?: string | null; [key: string]: unknown } | null;
253
+ encrypted?: boolean;
254
+ vault_id?: string;
255
+ ingot_id?: string;
218
256
  }
219
257
 
220
258
  interface IngotAuditLogsResponse {
221
- entries?: IngotAuditLogEntry[];
259
+ entries?: IngotAuditEntry[];
222
260
  next_cursor?: string;
223
261
  }
224
262
 
@@ -243,6 +281,28 @@ function toTypedDownloadError(err: unknown): unknown {
243
281
  return err;
244
282
  }
245
283
 
284
+ /**
285
+ * A create refused over its holder (`complete_pending` over a published one,
286
+ * `refuse` over any) answers 409 CONFLICT with `details.reason === 'NAME_HELD'`
287
+ * and the holder the server read. Every other 409 on the create (a folder
288
+ * being deleted, a cross-manager name) carries no holder and stays a plain
289
+ * `SparkVaultMobileError`.
290
+ */
291
+ function toTypedCreateError(err: unknown): unknown {
292
+ if (!(err instanceof SparkVaultMobileError) || err.statusCode !== 409 || err.details?.reason !== 'NAME_HELD') {
293
+ return err;
294
+ }
295
+ const { ingot_id: ingotId, status, size_bytes: sizeBytes } = err.details;
296
+ if (typeof ingotId !== 'string' || typeof status !== 'string' || typeof sizeBytes !== 'number') {
297
+ return err;
298
+ }
299
+ return new IngotNameHeldError(err.message, {
300
+ holder: { ingotId, status, sizeBytes },
301
+ details: err.details,
302
+ meta: err.meta,
303
+ });
304
+ }
305
+
246
306
  export class MobileIngotsClient {
247
307
  // Server-side finalization (encryption) of a just-uploaded ingot runs
248
308
  // asynchronously and scales with file size, so a large video can sit in
@@ -280,21 +340,23 @@ export class MobileIngotsClient {
280
340
  folder: options.folder,
281
341
  folderId: options.folderId,
282
342
  contentHmac: options.contentHmac,
343
+ onNameConflict: options.onNameConflict,
283
344
  });
284
345
  const created = init.created ?? true;
346
+ const name = init.name ?? options.name;
285
347
 
286
- // The ingot row exists but no Forge session does: a caller that refuses
287
- // to replace an existing ingot (created: false) aborts here, before a
288
- // byte moves, and its error reaches it untouched.
289
- options.onIngotCreated?.({ ingotId: init.ingotId, created });
348
+ // The placement is written but no Forge session exists yet: a throw from
349
+ // the hook stops the upload before a byte moves and reaches the caller
350
+ // untouched.
351
+ options.onIngotCreated?.({ ingotId: init.ingotId, created, name });
290
352
 
291
353
  await this.tus.uploadFromUri({
292
354
  fileUri: options.fileUri,
293
355
  fileSize: options.fileSize,
294
- filename: options.name,
295
- contentType: options.contentType,
356
+ filename: name,
296
357
  forgeUrl: init.forgeUrl,
297
358
  onSessionCreated: session => options.onSessionCreated?.({ ...session, ingotId: init.ingotId, created }),
359
+ onSessionExpiry: options.onSessionExpiry,
298
360
  transport: options.transport,
299
361
  transferPolicy: options.transferPolicy,
300
362
  onProgress: options.onProgress,
@@ -303,11 +365,15 @@ export class MobileIngotsClient {
303
365
  debug: options.debug,
304
366
  });
305
367
 
306
- const ingot = await this.verifyIngotActive(options.vaultId, init.ingotId, options.vat, options.debug);
368
+ const ingot = await this.verifyIngotActive(
369
+ { vaultId: options.vaultId, ingotId: init.ingotId, vat: options.vat },
370
+ { filename: name, fileSize: options.fileSize },
371
+ options.debug
372
+ );
307
373
 
308
374
  return {
309
375
  ingot_id: init.ingotId,
310
- name: init.name ?? options.name,
376
+ name,
311
377
  size_bytes: init.sizeBytes ?? options.fileSize,
312
378
  storage_location: 's3',
313
379
  // A server that does not report it minted the row (the upsert is the
@@ -325,8 +391,10 @@ export class MobileIngotsClient {
325
391
  * sent, then the ingot is verified active exactly as a fresh upload is.
326
392
  *
327
393
  * A `TusUploadError` with `sessionLost` means Forge no longer has the
328
- * session (expired or terminated); the caller drops it and starts over
329
- * with `uploadFromUri`.
394
+ * session (its expiry passed, or it was terminated); the caller drops it
395
+ * and starts over with `uploadFromUri`. A session `uploadUrl` that is not
396
+ * an upload session on an allowed transfer host is refused
397
+ * (SparkVaultValidationError) before the ISTK or a byte is sent anywhere.
330
398
  */
331
399
  async resumeUpload(options: ResumeUploadOptions): Promise<UploadResult> {
332
400
  validateVaultId(options.vaultId);
@@ -339,8 +407,8 @@ export class MobileIngotsClient {
339
407
  fileUri: options.fileUri,
340
408
  fileSize: options.fileSize,
341
409
  filename: options.name,
342
- contentType: options.contentType,
343
410
  resume: options.session,
411
+ onSessionExpiry: options.onSessionExpiry,
344
412
  transport: options.transport,
345
413
  transferPolicy: options.transferPolicy,
346
414
  onProgress: options.onProgress,
@@ -349,7 +417,11 @@ export class MobileIngotsClient {
349
417
  debug: options.debug,
350
418
  });
351
419
 
352
- const ingot = await this.verifyIngotActive(options.vaultId, options.ingotId, options.vat, options.debug);
420
+ const ingot = await this.verifyIngotActive(
421
+ { vaultId: options.vaultId, ingotId: options.ingotId, vat: options.vat },
422
+ { filename: options.name, fileSize: options.fileSize },
423
+ options.debug
424
+ );
353
425
 
354
426
  return {
355
427
  ingot_id: options.ingotId,
@@ -366,10 +438,10 @@ export class MobileIngotsClient {
366
438
  * longer has it. Lets a caller reconcile sessions on relaunch — a session
367
439
  * at `offset === length` only needs its ingot verified, a lost one needs a
368
440
  * fresh `uploadFromUri` — without spending an upload slot to find out.
441
+ * An `uploadUrl` that is not an upload session on an allowed transfer host
442
+ * is refused (SparkVaultValidationError) before the ISTK is sent anywhere.
369
443
  */
370
- probeUploadSession(
371
- session: { uploadUrl: string; istk: string }
372
- ): Promise<{ offset: number; length: number; chunkSize: number } | null> {
444
+ probeUploadSession(session: { uploadUrl: string; istk: string }): Promise<TusSessionProbe | null> {
373
445
  return this.tus.probeSession(session);
374
446
  }
375
447
 
@@ -379,6 +451,8 @@ export class MobileIngotsClient {
379
451
  * the partial transfer's billing instead of waiting for expiry. Resolves
380
452
  * once the session is gone, including when Forge no longer had it
381
453
  * (404/410); any other failure rejects so the caller can retry later.
454
+ * An `uploadUrl` that is not an upload session on an allowed transfer host
455
+ * is refused (SparkVaultValidationError) before the ISTK is sent anywhere.
382
456
  */
383
457
  terminateUploadSession(session: { uploadUrl: string; istk: string }): Promise<void> {
384
458
  return this.tus.terminateSession(session);
@@ -406,11 +480,14 @@ export class MobileIngotsClient {
406
480
  if (options.contentHmac) {
407
481
  body.content_hmac = options.contentHmac;
408
482
  }
409
- const response = await this.http.post<IngotUploadInitResponse>(
410
- `/vaults/${options.vaultId}/ingots`,
411
- body,
412
- { vat: options.vat }
413
- );
483
+ if (options.onNameConflict) {
484
+ body.on_name_conflict = options.onNameConflict;
485
+ }
486
+ const response = await this.http
487
+ .post<IngotUploadInitResponse>(apiPath`/vaults/${options.vaultId}/ingots`, body, { vat: options.vat })
488
+ .catch((err: unknown) => {
489
+ throw toTypedCreateError(err);
490
+ });
414
491
 
415
492
  if (!response.data.ingot_id || !response.data.forge_url) {
416
493
  throw new SparkVaultMobileError('Server returned invalid response for ingot creation');
@@ -435,7 +512,7 @@ export class MobileIngotsClient {
435
512
  }
436
513
 
437
514
  const response = await this.http.put<IngotUploadInitResponse>(
438
- `/vaults/${vaultId}/ingots/${ingotId}`,
515
+ apiPath`/vaults/${vaultId}/ingots/${ingotId}`,
439
516
  {
440
517
  name,
441
518
  content_type: contentType,
@@ -453,10 +530,10 @@ export class MobileIngotsClient {
453
530
  fileUri,
454
531
  fileSize,
455
532
  filename: name,
456
- contentType,
457
533
  forgeUrl: response.data.forge_url,
458
534
  // A replace never mints an ingot: the row is the caller's existing one.
459
535
  onSessionCreated: session => options.onSessionCreated?.({ ...session, ingotId, created: false }),
536
+ onSessionExpiry: options.onSessionExpiry,
460
537
  transport: options.transport,
461
538
  transferPolicy: options.transferPolicy,
462
539
  onProgress,
@@ -465,7 +542,11 @@ export class MobileIngotsClient {
465
542
  debug,
466
543
  });
467
544
 
468
- const ingot = await this.verifyIngotActive(vaultId, response.data.ingot_id, vat, debug);
545
+ const ingot = await this.verifyIngotActive(
546
+ { vaultId, ingotId: response.data.ingot_id, vat },
547
+ { filename: name, fileSize },
548
+ debug
549
+ );
469
550
 
470
551
  return {
471
552
  ingot_id: response.data.ingot_id,
@@ -490,7 +571,7 @@ export class MobileIngotsClient {
490
571
  ): Promise<HashCheckResponse> {
491
572
  validateVaultId(vaultId);
492
573
  const response = await this.http.post<HashCheckResponse>(
493
- `/vaults/${vaultId}/ingots/hash-check`,
574
+ apiPath`/vaults/${vaultId}/ingots/hash-check`,
494
575
  { hashes },
495
576
  { vat }
496
577
  );
@@ -500,7 +581,7 @@ export class MobileIngotsClient {
500
581
  async rename(vaultId: string, ingotId: string, vat: string, name: string): Promise<Ingot> {
501
582
  validateVaultId(vaultId);
502
583
  validateIngotId(ingotId);
503
- const response = await this.http.patch<Ingot>(`/vaults/${vaultId}/ingots/${ingotId}`, { name }, { vat });
584
+ const response = await this.http.patch<Ingot>(apiPath`/vaults/${vaultId}/ingots/${ingotId}`, { name }, { vat });
504
585
  return response.data;
505
586
  }
506
587
 
@@ -510,44 +591,37 @@ export class MobileIngotsClient {
510
591
  options?: { limit?: number; cursor?: string }
511
592
  ): Promise<ListIngotsResponse> {
512
593
  validateVaultId(vaultId);
513
- const params = new URLSearchParams();
514
- if (options?.limit) params.append('limit', options.limit.toString());
515
- if (options?.cursor) params.append('cursor', options.cursor);
516
-
517
- const query = params.toString();
518
- const response = await this.http.get<ListIngotsResponse>(
519
- query ? `/vaults/${vaultId}/ingots?${query}` : `/vaults/${vaultId}/ingots`,
520
- { vat }
521
- );
594
+ const response = await this.http.get<ListIngotsResponse>(apiPath`/vaults/${vaultId}/ingots`, {
595
+ vat,
596
+ query: { limit: options?.limit || undefined, cursor: options?.cursor },
597
+ });
522
598
  return response.data;
523
599
  }
524
600
 
601
+ /**
602
+ * List a folder, or the vault root, whole: the folder, its breadcrumb, its
603
+ * direct subfolders and every ingot directly in it, in one response.
604
+ */
525
605
  async listContents(
526
606
  vaultId: string,
527
607
  vat: string,
528
608
  options?: {
529
609
  folder_id?: string | null;
530
- cursor?: string;
531
- limit?: number;
610
+ /** Ingot sort field (name | size | content_type | created | access_count | bandwidth); `created` by default */
532
611
  sort_by?: string;
612
+ /** `desc` by default */
533
613
  sort_order?: 'asc' | 'desc';
534
- type?: string;
535
614
  }
536
- ): Promise<ListIngotsResponse> {
615
+ ): Promise<FolderContentsResponse> {
537
616
  validateVaultId(vaultId);
538
- const params = new URLSearchParams();
539
- if (options?.folder_id) params.append('folder_id', options.folder_id);
540
- if (options?.cursor) params.append('cursor', options.cursor);
541
- if (options?.limit) params.append('limit', options.limit.toString());
542
- if (options?.sort_by) params.append('sort_by', options.sort_by);
543
- if (options?.sort_order) params.append('sort_order', options.sort_order);
544
- if (options?.type && options.type !== 'all') params.append('type', options.type);
545
-
546
- const query = params.toString();
547
- const response = await this.http.get<ListIngotsResponse>(
548
- query ? `/vaults/${vaultId}/contents?${query}` : `/vaults/${vaultId}/contents`,
549
- { vat }
550
- );
617
+ const response = await this.http.get<FolderContentsResponse>(apiPath`/vaults/${vaultId}/contents`, {
618
+ vat,
619
+ query: {
620
+ folder_id: options?.folder_id,
621
+ sort_by: options?.sort_by,
622
+ sort_order: options?.sort_order,
623
+ },
624
+ });
551
625
  return response.data;
552
626
  }
553
627
 
@@ -566,35 +640,36 @@ export class MobileIngotsClient {
566
640
  }
567
641
  ): Promise<ListIngotsResponse> {
568
642
  validateVaultId(vaultId);
569
- const params = new URLSearchParams();
570
- if (options?.q) params.append('q', options.q);
571
- if (options?.file_type && options.file_type !== 'all') params.append('file_type', options.file_type);
572
- if (options?.folder_id) params.append('folder_id', options.folder_id);
573
- if (options?.min_size !== undefined) params.append('min_size', options.min_size.toString());
574
- if (options?.max_size !== undefined) params.append('max_size', options.max_size.toString());
575
- if (options?.limit) params.append('limit', options.limit.toString());
576
- if (options?.cursor) params.append('cursor', options.cursor);
577
- if (options?.sort_order) params.append('sort_order', options.sort_order);
578
-
579
- const query = params.toString();
580
- const response = await this.http.get<ListIngotsResponse>(
581
- query ? `/vaults/${vaultId}/ingots/search?${query}` : `/vaults/${vaultId}/ingots/search`,
582
- { vat }
583
- );
643
+ const response = await this.http.get<ListIngotsResponse>(apiPath`/vaults/${vaultId}/ingots/search`, {
644
+ vat,
645
+ query: {
646
+ q: options?.q,
647
+ file_type: options?.file_type === 'all' ? undefined : options?.file_type,
648
+ folder_id: options?.folder_id,
649
+ min_size: options?.min_size,
650
+ max_size: options?.max_size,
651
+ limit: options?.limit || undefined,
652
+ cursor: options?.cursor,
653
+ sort_order: options?.sort_order,
654
+ },
655
+ });
584
656
  return response.data;
585
657
  }
586
658
 
587
659
  async get(vaultId: string, ingotId: string, vat: string, options?: { consistent?: boolean }): Promise<Ingot> {
588
660
  validateVaultId(vaultId);
589
661
  validateIngotId(ingotId);
590
- const response = await this.http.get<Ingot>(`/vaults/${vaultId}/ingots/${ingotId}${options?.consistent ? "?consistent=true" : ""}`, { vat });
662
+ const response = await this.http.get<Ingot>(apiPath`/vaults/${vaultId}/ingots/${ingotId}`, {
663
+ vat,
664
+ query: { consistent: options?.consistent ? 'true' : undefined },
665
+ });
591
666
  return response.data;
592
667
  }
593
668
 
594
669
  async delete(vaultId: string, ingotId: string, vat: string): Promise<void> {
595
670
  validateVaultId(vaultId);
596
671
  validateIngotId(ingotId);
597
- await this.http.delete(`/vaults/${vaultId}/ingots/${ingotId}`, { vat });
672
+ await this.http.delete(apiPath`/vaults/${vaultId}/ingots/${ingotId}`, { vat });
598
673
  }
599
674
 
600
675
  /**
@@ -610,7 +685,7 @@ export class MobileIngotsClient {
610
685
 
611
686
  const response = await this.http
612
687
  .post<{ download_url?: string; content_version?: string | null }>(
613
- `/vaults/${vaultId}/ingots/${ingotId}/download`,
688
+ apiPath`/vaults/${vaultId}/ingots/${ingotId}/download`,
614
689
  undefined,
615
690
  { vat }
616
691
  )
@@ -719,7 +794,7 @@ export class MobileIngotsClient {
719
794
  validateVaultId(vaultId);
720
795
  validateIngotId(ingotId);
721
796
  const response = await this.http.get<IngotSharingConfig>(
722
- `/vaults/${vaultId}/ingots/${ingotId}/sharing`,
797
+ apiPath`/vaults/${vaultId}/ingots/${ingotId}/sharing`,
723
798
  { vat }
724
799
  );
725
800
  return response.data;
@@ -737,7 +812,7 @@ export class MobileIngotsClient {
737
812
  validateVaultId(vaultId);
738
813
  validateIngotId(ingotId);
739
814
  const response = await this.http.put<IngotSharingConfig>(
740
- `/vaults/${vaultId}/ingots/${ingotId}/sharing`,
815
+ apiPath`/vaults/${vaultId}/ingots/${ingotId}/sharing`,
741
816
  config,
742
817
  { vat }
743
818
  );
@@ -754,7 +829,7 @@ export class MobileIngotsClient {
754
829
  validateVaultId(vaultId);
755
830
  validateIngotId(ingotId);
756
831
  const response = await this.http.post<IngotInvite>(
757
- `/vaults/${vaultId}/ingots/${ingotId}/sharing/invite`,
832
+ apiPath`/vaults/${vaultId}/ingots/${ingotId}/sharing/invite`,
758
833
  { identity, expires_in_seconds: options.expiresInSeconds },
759
834
  { vat }
760
835
  );
@@ -764,7 +839,8 @@ export class MobileIngotsClient {
764
839
  async revokeInvite(vaultId: string, ingotId: string, vat: string, inviteId: string): Promise<void> {
765
840
  validateVaultId(vaultId);
766
841
  validateIngotId(ingotId);
767
- await this.http.delete(`/vaults/${vaultId}/ingots/${ingotId}/sharing/invite/${inviteId}`, { vat });
842
+ validateResourceId(inviteId, 'invite ID', 'inviteId');
843
+ await this.http.delete(apiPath`/vaults/${vaultId}/ingots/${ingotId}/sharing/invite/${inviteId}`, { vat });
768
844
  }
769
845
 
770
846
  async getAccessLogs(
@@ -775,22 +851,25 @@ export class MobileIngotsClient {
775
851
  ): Promise<{ logs: IngotAccessLog[]; next_cursor?: string }> {
776
852
  validateVaultId(vaultId);
777
853
  validateIngotId(ingotId);
778
- const params = new URLSearchParams();
779
- if (options?.limit) params.append('limit', options.limit.toString());
780
- if (options?.cursor) params.append('cursor', options.cursor);
781
-
782
- const query = params.toString();
783
854
  const response = await this.http.get<IngotAuditLogsResponse>(
784
- query
785
- ? `/vaults/${vaultId}/ingots/${ingotId}/audit-logs?${query}`
786
- : `/vaults/${vaultId}/ingots/${ingotId}/audit-logs`,
787
- { vat }
855
+ apiPath`/vaults/${vaultId}/ingots/${ingotId}/audit-logs`,
856
+ {
857
+ vat,
858
+ query: {
859
+ limit: options?.limit || undefined,
860
+ cursor: options?.cursor,
861
+ event_types: INGOT_ACCESS_EVENT_TYPES.join(','),
862
+ },
863
+ }
788
864
  );
789
- const entries = response.data.entries ?? [];
790
- return {
791
- logs: entries.map(entry => this.toAccessLog(entry)),
792
- next_cursor: response.data.next_cursor,
793
- };
865
+ const logs: IngotAccessLog[] = [];
866
+ for (const entry of response.data.entries ?? []) {
867
+ const access = readIngotAccess(entry);
868
+ if (access) {
869
+ logs.push({ log_id: String(access.timestamp), ...access });
870
+ }
871
+ }
872
+ return { logs, next_cursor: response.data.next_cursor };
794
873
  }
795
874
 
796
875
  async getAccessStats(vaultId: string, ingotId: string, vat: string): Promise<IngotAccessStats> {
@@ -813,39 +892,39 @@ export class MobileIngotsClient {
813
892
  }
814
893
 
815
894
  /**
816
- * Wait for a just-uploaded ingot to finish server-side finalization.
895
+ * Read a just-uploaded ingot until the server reports it `active`.
817
896
  *
818
- * The bytes are already fully uploaded and the ingot record exists before
819
- * this runs, so this NEVER re-creates or re-uploads anything - it only polls
820
- * status. A `failed` status throws (a genuine server encryption error the
821
- * caller should surface). A still-`processing` ingot after the poll budget
822
- * is returned as success, not thrown: the transport completed and the ingot
823
- * exists, so treating it as a failure would make the caller re-run the whole
824
- * upload and mint a DUPLICATE ingot (re-streaming the entire file and
825
- * double-billing storage). Finalization completes asynchronously server-side.
897
+ * The upload resolved on Forge's receipt (a 2xx to the PATCH at the final
898
+ * offset, which Forge answers only once the ingot is published), so a read
899
+ * that is not `active` yet is a read lagging that publication, which the
900
+ * poll waits out. It never re-creates or re-uploads anything. A row still
901
+ * not `active` past the poll budget is never reported as a success: it
902
+ * throws a `TusUploadError` whose `retryLater` is set (every byte is
903
+ * received, publication is unconfirmed), so the caller resumes the session
904
+ * it persisted later, which asks Forge for the receipt again and sends no
905
+ * bytes, rather than recording a backup the vault may not hold.
826
906
  */
827
907
  private async verifyIngotActive(
828
- vaultId: string,
829
- ingotId: string,
830
- vat: string,
908
+ ingotRef: { vaultId: string; ingotId: string; vat: string },
909
+ upload: { filename: string; fileSize: number },
831
910
  debug?: DebugLogger
832
911
  ): Promise<Ingot> {
912
+ const { vaultId, ingotId, vat } = ingotRef;
833
913
  const deadline = Date.now() + MobileIngotsClient.INGOT_STATUS_POLL_BUDGET_MS;
834
914
  let delay = MobileIngotsClient.INGOT_STATUS_POLL_INITIAL_MS;
835
915
  let ingot = await this.get(vaultId, ingotId, vat);
836
916
 
837
917
  while (ingot.status !== 'active') {
838
- if (ingot.status === 'failed') {
839
- throw new SparkVaultMobileError('Upload failed: server encryption error');
840
- }
841
-
842
918
  const remaining = deadline - Date.now();
843
919
  if (remaining <= 0) {
844
920
  debug?.log(
845
921
  `Ingot ${ingotId} still '${ingot.status}' after ${MobileIngotsClient.INGOT_STATUS_POLL_BUDGET_MS}ms; ` +
846
- 'upload transport is complete, finalizing asynchronously'
922
+ 'the upload is received and will be confirmed on a later attempt'
923
+ );
924
+ throw new TusUploadError(
925
+ `Upload received; the vault has not confirmed ingot ${ingotId} published yet (status ${ingot.status ?? 'unknown'})`,
926
+ { ...upload, phase: 'finalize', retryLater: true }
847
927
  );
848
- return ingot;
849
928
  }
850
929
 
851
930
  await new Promise(resolve => setTimeout(resolve, Math.min(delay, remaining)));
@@ -856,28 +935,6 @@ export class MobileIngotsClient {
856
935
  return ingot;
857
936
  }
858
937
 
859
- private toAccessLog(entry: IngotAuditLogEntry): IngotAccessLog {
860
- const eventType = entry.event_type ?? entry.action ?? '';
861
- const action = eventType.includes('download') ? 'download' : 'view';
862
- const actor = entry.actor;
863
- return {
864
- log_id: entry.log_id ?? entry.event_id ?? entry.id ?? String(entry.timestamp ?? entry.created_at ?? ''),
865
- action,
866
- identity:
867
- actor?.email ??
868
- entry.actor_email ??
869
- entry.identity ??
870
- actor?.id ??
871
- actor?.user_id ??
872
- actor?.api_key_id ??
873
- entry.actor_id ??
874
- 'Unknown',
875
- ip_address: entry.ip_address ?? '',
876
- user_agent: entry.user_agent ?? '',
877
- timestamp: entry.timestamp ?? entry.created_at ?? 0,
878
- };
879
- }
880
-
881
938
  private async verifyDownloadedFile(
882
939
  fileDownloader: MobileFileDownloader,
883
940
  preferredUri: string,
@@ -953,26 +1010,14 @@ export class MobileIngotsClient {
953
1010
  });
954
1011
  }
955
1012
 
1013
+ /** Refuse a download link that is not a Forge decrypt URL on a trusted host (forge-url's rule). */
956
1014
  private validateDownloadUrl(downloadUrl: string): void {
957
- let url: URL;
958
- try {
959
- url = new URL(downloadUrl);
960
- } catch {
961
- throw new SparkVaultValidationError('Invalid download URL from server');
962
- }
963
-
964
- if (url.protocol !== 'https:') {
965
- throw new SparkVaultValidationError('Invalid download URL from server', {
966
- reason: 'non_https_download_url',
967
- });
968
- }
969
-
970
- const allowed = this.config.allowedDownloadHostPatterns.some(pattern => pattern.test(url.hostname));
971
- if (!allowed) {
972
- throw new SparkVaultValidationError('Invalid download URL from server', {
973
- reason: 'download_host_not_allowed',
974
- hostname: url.hostname,
975
- });
1015
+ const read = readForgeTransferUrl(downloadUrl, {
1016
+ operation: 'decrypt',
1017
+ isAllowedHost: allowsHost(this.config.allowedDownloadHostPatterns),
1018
+ });
1019
+ if (!read.ok) {
1020
+ throw new SparkVaultValidationError('Invalid download URL from server', { reason: read.reason });
976
1021
  }
977
1022
  }
978
1023