@sparkvault/sdk-mobile 6.0.2 → 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 +51 -18
  73. package/dist/ingots.js +132 -122
  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 +167 -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 +173 -159
  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 +165 -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`. */
@@ -161,17 +189,20 @@ export interface CreateUploadOptions {
161
189
  folderId?: string;
162
190
  /** Vault-keyed plaintext HMAC (43-char base64url); see UploadOptions.contentHmac. */
163
191
  contentHmac?: string;
192
+ /** What a name already held in the target folder does; omitted means the server's `upsert`. */
193
+ onNameConflict?: NameConflictPolicy;
164
194
  }
165
195
 
166
196
  export interface CreateUploadResult {
167
197
  ingotId: string;
168
198
  /** Forge tus endpoint plus upload-session token (ISTK) for the resumable upload. */
169
199
  forgeUrl: string;
200
+ /** The name the ingot is stored under: the server's numbered name under `new_name`. */
170
201
  name?: string;
171
202
  sizeBytes?: number;
172
203
  /**
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.
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.
175
206
  */
176
207
  created?: boolean;
177
208
  }
@@ -196,29 +227,23 @@ interface IngotUploadInitResponse {
196
227
  created?: boolean;
197
228
  }
198
229
 
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;
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;
218
243
  }
219
244
 
220
245
  interface IngotAuditLogsResponse {
221
- entries?: IngotAuditLogEntry[];
246
+ entries?: IngotAuditEntry[];
222
247
  next_cursor?: string;
223
248
  }
224
249
 
@@ -243,6 +268,28 @@ function toTypedDownloadError(err: unknown): unknown {
243
268
  return err;
244
269
  }
245
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
+
246
293
  export class MobileIngotsClient {
247
294
  // Server-side finalization (encryption) of a just-uploaded ingot runs
248
295
  // asynchronously and scales with file size, so a large video can sit in
@@ -280,19 +327,20 @@ export class MobileIngotsClient {
280
327
  folder: options.folder,
281
328
  folderId: options.folderId,
282
329
  contentHmac: options.contentHmac,
330
+ onNameConflict: options.onNameConflict,
283
331
  });
284
332
  const created = init.created ?? true;
333
+ const name = init.name ?? options.name;
285
334
 
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 });
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 });
290
339
 
291
340
  await this.tus.uploadFromUri({
292
341
  fileUri: options.fileUri,
293
342
  fileSize: options.fileSize,
294
- filename: options.name,
295
- contentType: options.contentType,
343
+ filename: name,
296
344
  forgeUrl: init.forgeUrl,
297
345
  onSessionCreated: session => options.onSessionCreated?.({ ...session, ingotId: init.ingotId, created }),
298
346
  transport: options.transport,
@@ -307,7 +355,7 @@ export class MobileIngotsClient {
307
355
 
308
356
  return {
309
357
  ingot_id: init.ingotId,
310
- name: init.name ?? options.name,
358
+ name,
311
359
  size_bytes: init.sizeBytes ?? options.fileSize,
312
360
  storage_location: 's3',
313
361
  // A server that does not report it minted the row (the upsert is the
@@ -326,7 +374,9 @@ export class MobileIngotsClient {
326
374
  *
327
375
  * A `TusUploadError` with `sessionLost` means Forge no longer has the
328
376
  * session (expired or terminated); the caller drops it and starts over
329
- * 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.
330
380
  */
331
381
  async resumeUpload(options: ResumeUploadOptions): Promise<UploadResult> {
332
382
  validateVaultId(options.vaultId);
@@ -339,7 +389,6 @@ export class MobileIngotsClient {
339
389
  fileUri: options.fileUri,
340
390
  fileSize: options.fileSize,
341
391
  filename: options.name,
342
- contentType: options.contentType,
343
392
  resume: options.session,
344
393
  transport: options.transport,
345
394
  transferPolicy: options.transferPolicy,
@@ -366,6 +415,8 @@ export class MobileIngotsClient {
366
415
  * longer has it. Lets a caller reconcile sessions on relaunch — a session
367
416
  * at `offset === length` only needs its ingot verified, a lost one needs a
368
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.
369
420
  */
370
421
  probeUploadSession(
371
422
  session: { uploadUrl: string; istk: string }
@@ -379,6 +430,8 @@ export class MobileIngotsClient {
379
430
  * the partial transfer's billing instead of waiting for expiry. Resolves
380
431
  * once the session is gone, including when Forge no longer had it
381
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.
382
435
  */
383
436
  terminateUploadSession(session: { uploadUrl: string; istk: string }): Promise<void> {
384
437
  return this.tus.terminateSession(session);
@@ -406,11 +459,14 @@ export class MobileIngotsClient {
406
459
  if (options.contentHmac) {
407
460
  body.content_hmac = options.contentHmac;
408
461
  }
409
- const response = await this.http.post<IngotUploadInitResponse>(
410
- `/vaults/${options.vaultId}/ingots`,
411
- body,
412
- { vat: options.vat }
413
- );
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
+ });
414
470
 
415
471
  if (!response.data.ingot_id || !response.data.forge_url) {
416
472
  throw new SparkVaultMobileError('Server returned invalid response for ingot creation');
@@ -435,7 +491,7 @@ export class MobileIngotsClient {
435
491
  }
436
492
 
437
493
  const response = await this.http.put<IngotUploadInitResponse>(
438
- `/vaults/${vaultId}/ingots/${ingotId}`,
494
+ apiPath`/vaults/${vaultId}/ingots/${ingotId}`,
439
495
  {
440
496
  name,
441
497
  content_type: contentType,
@@ -453,7 +509,6 @@ export class MobileIngotsClient {
453
509
  fileUri,
454
510
  fileSize,
455
511
  filename: name,
456
- contentType,
457
512
  forgeUrl: response.data.forge_url,
458
513
  // A replace never mints an ingot: the row is the caller's existing one.
459
514
  onSessionCreated: session => options.onSessionCreated?.({ ...session, ingotId, created: false }),
@@ -490,7 +545,7 @@ export class MobileIngotsClient {
490
545
  ): Promise<HashCheckResponse> {
491
546
  validateVaultId(vaultId);
492
547
  const response = await this.http.post<HashCheckResponse>(
493
- `/vaults/${vaultId}/ingots/hash-check`,
548
+ apiPath`/vaults/${vaultId}/ingots/hash-check`,
494
549
  { hashes },
495
550
  { vat }
496
551
  );
@@ -500,7 +555,7 @@ export class MobileIngotsClient {
500
555
  async rename(vaultId: string, ingotId: string, vat: string, name: string): Promise<Ingot> {
501
556
  validateVaultId(vaultId);
502
557
  validateIngotId(ingotId);
503
- 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 });
504
559
  return response.data;
505
560
  }
506
561
 
@@ -510,44 +565,37 @@ export class MobileIngotsClient {
510
565
  options?: { limit?: number; cursor?: string }
511
566
  ): Promise<ListIngotsResponse> {
512
567
  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
- );
568
+ const response = await this.http.get<ListIngotsResponse>(apiPath`/vaults/${vaultId}/ingots`, {
569
+ vat,
570
+ query: { limit: options?.limit || undefined, cursor: options?.cursor },
571
+ });
522
572
  return response.data;
523
573
  }
524
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
+ */
525
579
  async listContents(
526
580
  vaultId: string,
527
581
  vat: string,
528
582
  options?: {
529
583
  folder_id?: string | null;
530
- cursor?: string;
531
- limit?: number;
584
+ /** Ingot sort field (name | size | content_type | created | access_count | bandwidth); `created` by default */
532
585
  sort_by?: string;
586
+ /** `desc` by default */
533
587
  sort_order?: 'asc' | 'desc';
534
- type?: string;
535
588
  }
536
- ): Promise<ListIngotsResponse> {
589
+ ): Promise<FolderContentsResponse> {
537
590
  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
- );
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
+ });
551
599
  return response.data;
552
600
  }
553
601
 
@@ -566,35 +614,36 @@ export class MobileIngotsClient {
566
614
  }
567
615
  ): Promise<ListIngotsResponse> {
568
616
  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
- );
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
+ });
584
630
  return response.data;
585
631
  }
586
632
 
587
633
  async get(vaultId: string, ingotId: string, vat: string, options?: { consistent?: boolean }): Promise<Ingot> {
588
634
  validateVaultId(vaultId);
589
635
  validateIngotId(ingotId);
590
- const response = await this.http.get<Ingot>(`/vaults/${vaultId}/ingots/${ingotId}${options?.consistent ? "?consistent=true" : ""}`, { vat });
636
+ const response = await this.http.get<Ingot>(apiPath`/vaults/${vaultId}/ingots/${ingotId}`, {
637
+ vat,
638
+ query: { consistent: options?.consistent ? 'true' : undefined },
639
+ });
591
640
  return response.data;
592
641
  }
593
642
 
594
643
  async delete(vaultId: string, ingotId: string, vat: string): Promise<void> {
595
644
  validateVaultId(vaultId);
596
645
  validateIngotId(ingotId);
597
- await this.http.delete(`/vaults/${vaultId}/ingots/${ingotId}`, { vat });
646
+ await this.http.delete(apiPath`/vaults/${vaultId}/ingots/${ingotId}`, { vat });
598
647
  }
599
648
 
600
649
  /**
@@ -610,7 +659,7 @@ export class MobileIngotsClient {
610
659
 
611
660
  const response = await this.http
612
661
  .post<{ download_url?: string; content_version?: string | null }>(
613
- `/vaults/${vaultId}/ingots/${ingotId}/download`,
662
+ apiPath`/vaults/${vaultId}/ingots/${ingotId}/download`,
614
663
  undefined,
615
664
  { vat }
616
665
  )
@@ -719,7 +768,7 @@ export class MobileIngotsClient {
719
768
  validateVaultId(vaultId);
720
769
  validateIngotId(ingotId);
721
770
  const response = await this.http.get<IngotSharingConfig>(
722
- `/vaults/${vaultId}/ingots/${ingotId}/sharing`,
771
+ apiPath`/vaults/${vaultId}/ingots/${ingotId}/sharing`,
723
772
  { vat }
724
773
  );
725
774
  return response.data;
@@ -737,7 +786,7 @@ export class MobileIngotsClient {
737
786
  validateVaultId(vaultId);
738
787
  validateIngotId(ingotId);
739
788
  const response = await this.http.put<IngotSharingConfig>(
740
- `/vaults/${vaultId}/ingots/${ingotId}/sharing`,
789
+ apiPath`/vaults/${vaultId}/ingots/${ingotId}/sharing`,
741
790
  config,
742
791
  { vat }
743
792
  );
@@ -754,7 +803,7 @@ export class MobileIngotsClient {
754
803
  validateVaultId(vaultId);
755
804
  validateIngotId(ingotId);
756
805
  const response = await this.http.post<IngotInvite>(
757
- `/vaults/${vaultId}/ingots/${ingotId}/sharing/invite`,
806
+ apiPath`/vaults/${vaultId}/ingots/${ingotId}/sharing/invite`,
758
807
  { identity, expires_in_seconds: options.expiresInSeconds },
759
808
  { vat }
760
809
  );
@@ -764,7 +813,8 @@ export class MobileIngotsClient {
764
813
  async revokeInvite(vaultId: string, ingotId: string, vat: string, inviteId: string): Promise<void> {
765
814
  validateVaultId(vaultId);
766
815
  validateIngotId(ingotId);
767
- 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 });
768
818
  }
769
819
 
770
820
  async getAccessLogs(
@@ -775,22 +825,25 @@ export class MobileIngotsClient {
775
825
  ): Promise<{ logs: IngotAccessLog[]; next_cursor?: string }> {
776
826
  validateVaultId(vaultId);
777
827
  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
828
  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 }
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
+ }
788
838
  );
789
- const entries = response.data.entries ?? [];
790
- return {
791
- logs: entries.map(entry => this.toAccessLog(entry)),
792
- next_cursor: response.data.next_cursor,
793
- };
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 };
794
847
  }
795
848
 
796
849
  async getAccessStats(vaultId: string, ingotId: string, vat: string): Promise<IngotAccessStats> {
@@ -817,8 +870,7 @@ export class MobileIngotsClient {
817
870
  *
818
871
  * The bytes are already fully uploaded and the ingot record exists before
819
872
  * 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
873
+ * status. A still-`uploading` ingot after the poll budget
822
874
  * is returned as success, not thrown: the transport completed and the ingot
823
875
  * exists, so treating it as a failure would make the caller re-run the whole
824
876
  * upload and mint a DUPLICATE ingot (re-streaming the entire file and
@@ -835,10 +887,6 @@ export class MobileIngotsClient {
835
887
  let ingot = await this.get(vaultId, ingotId, vat);
836
888
 
837
889
  while (ingot.status !== 'active') {
838
- if (ingot.status === 'failed') {
839
- throw new SparkVaultMobileError('Upload failed: server encryption error');
840
- }
841
-
842
890
  const remaining = deadline - Date.now();
843
891
  if (remaining <= 0) {
844
892
  debug?.log(
@@ -856,28 +904,6 @@ export class MobileIngotsClient {
856
904
  return ingot;
857
905
  }
858
906
 
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
907
  private async verifyDownloadedFile(
882
908
  fileDownloader: MobileFileDownloader,
883
909
  preferredUri: string,
@@ -953,26 +979,14 @@ export class MobileIngotsClient {
953
979
  });
954
980
  }
955
981
 
982
+ /** Refuse a download link that is not a Forge decrypt URL on a trusted host (forge-url's rule). */
956
983
  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
- });
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 });
976
990
  }
977
991
  }
978
992
 
package/src/mutex.ts CHANGED
@@ -1,7 +1,6 @@
1
+ /** Runs one operation at a time; callers arriving while it runs share its result. */
1
2
  export interface DedupMutex<T> {
2
3
  dedupe(fn: () => Promise<T>): Promise<T>;
3
- isRunning(): boolean;
4
- reset(): void;
5
4
  }
6
5
 
7
6
  export function createDedupMutex<T>(): DedupMutex<T> {
@@ -16,11 +15,5 @@ export function createDedupMutex<T>(): DedupMutex<T> {
16
15
  }
17
16
  return current;
18
17
  },
19
- isRunning(): boolean {
20
- return current !== null;
21
- },
22
- reset(): void {
23
- current = null;
24
- },
25
18
  };
26
19
  }
@@ -11,7 +11,7 @@
11
11
  * which would put these assertions exactly where the compiler cannot see them.
12
12
  *
13
13
  * The row shape is shared verbatim with `@sparkvault/sdk-js`, whose own contract
14
- * file asserts the same three members — both must move together, and the mirrored
14
+ * file asserts the same four members — both must move together, and the mirrored
15
15
  * server source is `apps/api/src/notify/inbox.js` (`projectRow`).
16
16
  */
17
17
 
@@ -39,4 +39,6 @@ export type NotifyWireContract = [
39
39
  // The pointer is withheld on a recalled row and absent on a cleartext one, so
40
40
  // it is optional in both directions and must be guarded before use.
41
41
  Assert<Equals<NotificationRow['sparklink_code'], string | undefined>>,
42
+ // The URL the pointer opens travels with it: absent exactly when the pointer is.
43
+ Assert<Equals<NotificationRow['pointer_url'], string | undefined>>,
42
44
  ];