@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/entropy.ts CHANGED
@@ -1,18 +1,34 @@
1
- import { base64ToBytes } from './encoding.js';
1
+ import { base64ToBytes } from './app-sdk/encoding.js';
2
+ import { ENTROPY_FORMATS, MAX_DRAW_BYTES, isEntropyFormat, maxBytesFor } from './app-sdk/entropy-contract.js';
2
3
  import { SparkVaultMobileError, SparkVaultValidationError } from './errors.js';
3
4
  import type { MobileHttpClient } from './http.js';
5
+ import { apiPath } from './request-path.js';
4
6
 
5
- export type EntropyFormat = 'hex' | 'base64' | 'bytes' | 'uuid';
7
+ /** An output format the entropy endpoint serves (the platform's entropy contract). */
8
+ export type EntropyFormat =
9
+ | 'hex'
10
+ | 'base64'
11
+ | 'base64url'
12
+ | 'alphanumeric'
13
+ | 'alphanumeric-mixed'
14
+ | 'password'
15
+ | 'numeric'
16
+ | 'uuid'
17
+ | 'bytes';
6
18
 
7
19
  export interface GenerateEntropyOptions {
20
+ /** Number of random bytes: 1–1024, or 1–512 for alphanumeric, alphanumeric-mixed and password. */
8
21
  bytes: number;
22
+ /** Output format. Defaults to base64. */
9
23
  format?: EntropyFormat;
10
24
  }
11
25
 
12
- export interface EntropyResponse {
13
- value: string;
26
+ /** The endpoint's answer: `value` is a number array for the `bytes` format and a string for every other. */
27
+ export interface EntropyResponse<Value extends string | number[] = string | number[]> {
28
+ value: Value;
14
29
  num_bytes: number;
15
30
  format?: EntropyFormat;
31
+ reference_id?: string;
16
32
  }
17
33
 
18
34
  export class MobileEntropyClient {
@@ -22,35 +38,85 @@ export class MobileEntropyClient {
22
38
  this.http = http;
23
39
  }
24
40
 
41
+ generate(options: GenerateEntropyOptions & { format: 'bytes' }): Promise<EntropyResponse<number[]>>;
42
+ generate(options: GenerateEntropyOptions & { format?: Exclude<EntropyFormat, 'bytes'> }): Promise<EntropyResponse<string>>;
43
+ generate(options: GenerateEntropyOptions): Promise<EntropyResponse>;
25
44
  async generate(options: GenerateEntropyOptions): Promise<EntropyResponse> {
26
- this.validateByteCount(options.bytes);
27
- const response = await this.http.post<EntropyResponse>('/entropy/generate', {
45
+ const format = options.format ?? 'base64';
46
+ validateEntropyRequest(options.bytes, format);
47
+ const response = await this.http.post<EntropyResponse>(apiPath`/entropy/generate`, {
28
48
  num_bytes: options.bytes,
29
- format: options.format ?? 'base64',
49
+ format,
30
50
  });
31
51
  return response.data;
32
52
  }
33
53
 
34
54
  async generateBytes(bytes: number): Promise<Uint8Array> {
35
55
  const response = await this.generate({ bytes, format: 'base64' });
36
- if (typeof response.value !== 'string' || response.value.length === 0) {
37
- throw new SparkVaultMobileError('Entropy endpoint returned no value');
38
- }
39
-
40
- const entropy = base64ToBytes(response.value);
41
- if (entropy.length !== bytes) {
42
- throw new SparkVaultMobileError(`Entropy length mismatch: expected ${bytes}, got ${entropy.length}`);
43
- }
44
-
45
- return entropy;
56
+ return drawnBytes(response.value, bytes);
46
57
  }
47
58
 
48
- private validateByteCount(bytes: number): void {
49
- if (!Number.isInteger(bytes) || bytes < 1 || bytes > 1024) {
50
- throw new SparkVaultValidationError('Entropy byte count must be an integer from 1 to 1024', {
59
+ /**
60
+ * Random bytes for the signed-in client's own working values (a key's id, a
61
+ * secret's server share), from POST /v1/entropy/first-party: answered to an
62
+ * interactive SparkVault session only, never plan- or bandwidth-gated and
63
+ * never recorded as the account's usage. Values an integration asks for come
64
+ * from `generate` / `generateBytes`.
65
+ *
66
+ * @param bytes - 1 to the entropy contract's ceiling (MAX_DRAW_BYTES)
67
+ * @returns exactly `bytes` bytes
68
+ */
69
+ async drawFirstParty(bytes: number): Promise<Uint8Array> {
70
+ if (!Number.isInteger(bytes) || bytes < 1 || bytes > MAX_DRAW_BYTES) {
71
+ throw new SparkVaultValidationError(`Entropy byte count must be an integer from 1 to ${MAX_DRAW_BYTES}`, {
51
72
  field: 'bytes',
52
73
  received: bytes,
53
74
  });
54
75
  }
76
+ const response = await this.http.post<{ value: unknown; num_bytes: number }>(apiPath`/entropy/first-party`, {
77
+ num_bytes: bytes,
78
+ });
79
+ return drawnBytes(response.data.value, bytes);
80
+ }
81
+ }
82
+
83
+ /**
84
+ * The bytes a draw answered as base64, refused unless they are exactly the
85
+ * bytes asked for, so a malformed answer fails the operation that asked for it.
86
+ */
87
+ function drawnBytes(value: unknown, bytes: number): Uint8Array {
88
+ if (typeof value !== 'string' || value.length === 0) {
89
+ throw new SparkVaultMobileError('Entropy endpoint returned no value');
90
+ }
91
+
92
+ const entropy = base64ToBytes(value);
93
+ if (entropy === null) {
94
+ throw new SparkVaultMobileError('Entropy endpoint returned a value that is not base64');
95
+ }
96
+ if (entropy.length !== bytes) {
97
+ throw new SparkVaultMobileError(`Entropy length mismatch: expected ${bytes}, got ${entropy.length}`);
98
+ }
99
+
100
+ return entropy;
101
+ }
102
+
103
+ /**
104
+ * Refuse a request the endpoint would refuse, before it is sent: the format must
105
+ * be one the endpoint serves, and the byte count an integer from 1 to that
106
+ * format's ceiling.
107
+ */
108
+ function validateEntropyRequest(bytes: number, format: EntropyFormat): void {
109
+ if (!isEntropyFormat(format)) {
110
+ throw new SparkVaultValidationError(`Entropy format must be one of: ${ENTROPY_FORMATS.join(', ')}`, {
111
+ field: 'format',
112
+ received: format,
113
+ });
114
+ }
115
+ const max = maxBytesFor(format);
116
+ if (!Number.isInteger(bytes) || bytes < 1 || bytes > max) {
117
+ throw new SparkVaultValidationError(`Entropy byte count must be an integer from 1 to ${max} for the ${format} format`, {
118
+ field: 'bytes',
119
+ received: bytes,
120
+ });
55
121
  }
56
122
  }
package/src/errors.ts CHANGED
@@ -1,7 +1,9 @@
1
1
  import type { ApiErrorBody, ApiMeta, BackgroundTransferFailureKind } from './types.js';
2
+ import { isTransientHttpStatus } from './app-sdk/transient.js';
3
+ import { VAT_REFUSAL_REASONS, vatRefusalReason as readVatRefusalReason } from './app-sdk/vat-refusal.js';
2
4
 
3
- // Only the byte pools hard-gate: flex licensing has no seat limit, and
4
- // Identity soft-gates through its own path.
5
+ // Only the byte pools hard-gate: licenses are assigned per member, never
6
+ // drawn from a pool, and Identity soft-gates through its own path.
5
7
  type QuotaResource = 'storage' | 'bandwidth';
6
8
 
7
9
  function isQuotaResource(value: unknown): value is QuotaResource {
@@ -115,10 +117,10 @@ export class SparkVaultTimeoutError extends SparkVaultMobileError {
115
117
  }
116
118
 
117
119
  /**
118
- * Thrown when a download is requested against an ingot that is still being
119
- * processed server-side (status `uploading`/`encrypting`). Transient by
120
- * nature, but retrying immediately will not help — callers should back off
121
- * until the ingot becomes active.
120
+ * Thrown when a download is requested against an ingot whose upload Forge has
121
+ * not activated yet (status `uploading`). Transient by nature, but retrying
122
+ * immediately will not help — callers should back off until the ingot becomes
123
+ * active.
122
124
  */
123
125
  export class IngotNotReadyError extends SparkVaultMobileError {
124
126
  /** Server-reported ingot status at the time of the request, when known. */
@@ -140,32 +142,101 @@ export class IngotNotReadyError extends SparkVaultMobileError {
140
142
  }
141
143
  }
142
144
 
145
+ /** The ingot a create was refused over, as the server read it. */
146
+ export interface IngotNameHolder {
147
+ ingotId: string;
148
+ /**
149
+ * Lifecycle status of the holder: `active` when its upload finished,
150
+ * `uploading` when it has not (only a `refuse` create is refused over an
151
+ * unfinished holder; `complete_pending` completes one).
152
+ */
153
+ status: string;
154
+ sizeBytes: number;
155
+ }
156
+
157
+ /**
158
+ * Thrown when a create is refused because an ingot already holds the name in
159
+ * the target folder (409 `details.reason === 'NAME_HELD'`): with
160
+ * `onNameConflict: 'complete_pending'` a published holder, with `'refuse'` any
161
+ * holder. The server decided before writing anything, so the holder is
162
+ * untouched; `holder` is the row it read, which lets the caller act on it
163
+ * (send its bytes into its own row by id, adopt it, or try another name)
164
+ * without a second read.
165
+ */
166
+ export class IngotNameHeldError extends SparkVaultMobileError {
167
+ readonly holder: IngotNameHolder;
168
+
169
+ constructor(
170
+ message: string,
171
+ options: { holder: IngotNameHolder; details?: Record<string, unknown>; meta?: ApiMeta }
172
+ ) {
173
+ super(message, { code: 'CONFLICT', statusCode: 409, details: options.details, meta: options.meta });
174
+ this.name = 'IngotNameHeldError';
175
+ this.holder = options.holder;
176
+ Object.setPrototypeOf(this, IngotNameHeldError.prototype);
177
+ }
178
+ }
179
+
143
180
  export class TusUploadError extends SparkVaultMobileError {
144
181
  readonly cause: Error | null;
145
182
  readonly filename: string | null;
146
183
  readonly fileSize: number | null;
147
- readonly phase: 'create' | 'upload' | 'network' | 'timeout' | 'cancelled' | 'stalled' | 'unknown';
184
+ /**
185
+ * Where the upload stopped: `finalize` is after the transfer, when every
186
+ * byte was on Forge but the ingot was not confirmed published.
187
+ */
188
+ readonly phase: 'create' | 'upload' | 'finalize' | 'network' | 'timeout' | 'cancelled' | 'stalled' | 'unknown';
148
189
  /**
149
190
  * True when Forge no longer has the session (it answered a status
150
- * `isSessionGone` recognises: expired or terminated), or the session
151
- * cannot carry this file. The persisted session is worthless, so the
152
- * caller starts a fresh upload instead of retrying it.
191
+ * `isSessionGone` recognises, or the expiry it named has passed), or the
192
+ * session cannot carry this file. The persisted session is worthless, so
193
+ * the caller starts a fresh upload instead of retrying it.
153
194
  */
154
195
  readonly sessionLost: boolean;
196
+ /**
197
+ * The `Retry-After` of the answer that ended the upload, in ms: the
198
+ * earliest a next attempt can expect another answer. Null when that answer
199
+ * named none, or no answer arrived.
200
+ */
201
+ readonly retryAfterMs: number | null;
202
+ /**
203
+ * True when every byte is on Forge and the session is intact but the ingot
204
+ * was not confirmed published: the vault did not read it `active` within
205
+ * the activation poll budget, or Forge was still saving the file at the
206
+ * upload's idle limit (code `upload_received_publishing`; it finishes
207
+ * without this device). The caller retries later by resuming the session
208
+ * it persisted, which asks Forge for the receipt again and sends no bytes.
209
+ */
210
+ readonly retryLater: boolean;
211
+ /**
212
+ * The code Core ended this upload with, as Forge named it. With code
213
+ * `upload_refused` a final refusal (`UPLOAD_POOL_FULL`: the account's
214
+ * storage pool cannot take it; `UPLOAD_SUPERSEDED`: a later upload replaced
215
+ * it), whose message is Core's own sentence and which no new upload of the
216
+ * same file remedies; with `sessionLost` any other code (`NOT_FOUND`: the
217
+ * file the upload was for is gone; `UPLOAD_PROTOCOL_RETIRED`; a 412's),
218
+ * which a fresh upload starts over from. Null when the answer named none.
219
+ */
220
+ readonly coreCode: string | null;
155
221
 
156
222
  constructor(
157
223
  message: string,
158
224
  options: {
159
225
  cause?: Error | null;
160
226
  httpStatus?: number | null;
227
+ /** The error's code when it names an outcome of its own; otherwise the HTTP status, or `tus_upload_error`. */
228
+ code?: string;
161
229
  filename?: string | null;
162
230
  fileSize?: number | null;
163
231
  phase?: TusUploadError['phase'];
164
232
  sessionLost?: boolean;
233
+ retryAfterMs?: number | null;
234
+ retryLater?: boolean;
235
+ coreCode?: string | null;
165
236
  } = {}
166
237
  ) {
167
238
  super(message, {
168
- code: options.httpStatus ?? 'tus_upload_error',
239
+ code: options.code ?? options.httpStatus ?? 'tus_upload_error',
169
240
  statusCode: options.httpStatus ?? undefined,
170
241
  });
171
242
  this.name = 'TusUploadError';
@@ -174,6 +245,9 @@ export class TusUploadError extends SparkVaultMobileError {
174
245
  this.fileSize = options.fileSize ?? null;
175
246
  this.phase = options.phase ?? 'unknown';
176
247
  this.sessionLost = options.sessionLost ?? false;
248
+ this.retryAfterMs = options.retryAfterMs ?? null;
249
+ this.retryLater = options.retryLater ?? false;
250
+ this.coreCode = options.coreCode ?? null;
177
251
  Object.setPrototypeOf(this, TusUploadError.prototype);
178
252
  }
179
253
 
@@ -259,8 +333,7 @@ export function isNetworkError(err: unknown): boolean {
259
333
  * per-upload token minted with the ingot (expired, consumed, or bound to a
260
334
  * different session) — not the vault session. Re-unsealing the vault cannot
261
335
  * fix it; the caller creates a new upload session (`POST /ingots`) instead.
262
- * Use `isVatExpiredError` / `isReadOnlyVaultError` to tell the API's VAT
263
- * rejections apart.
336
+ * Use `vatRefusalReason` to tell the API's VAT refusals apart.
264
337
  */
265
338
  export function isAuthError(err: unknown): boolean {
266
339
  return (
@@ -271,31 +344,51 @@ export function isAuthError(err: unknown): boolean {
271
344
  }
272
345
 
273
346
  /**
274
- * True when the API rejected the vault access token because it has expired
275
- * (`details.reason === 'VAT_EXPIRED'` on a 401 or 403 — an expired VAT
276
- * answers 403 for its first hour, then 401). Re-unsealing the vault mints a
277
- * fresh VAT and the same request can be retried as-is.
347
+ * The reason the API refused the vault access token, or null when the error
348
+ * is not a VAT refusal:
349
+ * - `VAT_INVALID` (401): no, a malformed, unknown or swept token, or one a
350
+ * since-revoked DVAK minted — unseal again. Never the login.
351
+ * - `VAT_EXPIRED` (403): the token outlived its TTL — unseal again and
352
+ * retry the same request as-is.
353
+ * - `VAT_READ_ONLY` (403): a View Only token on a write — no unseal fixes it.
354
+ * A 403 with no reason is a token for another vault or account; a 401 with no
355
+ * reason is the login itself.
356
+ */
357
+ export function vatRefusalReason(err: unknown): string | null {
358
+ if (!(err instanceof SparkVaultMobileError) || err instanceof TusUploadError) {
359
+ return null;
360
+ }
361
+ return readVatRefusalReason(err);
362
+ }
363
+
364
+ /**
365
+ * True when the API refused the vault access token because it has expired
366
+ * (403 `VAT_EXPIRED`). Re-unsealing the vault mints a fresh VAT and the same
367
+ * request can be retried as-is.
278
368
  */
279
369
  export function isVatExpiredError(err: unknown): err is SparkVaultMobileError {
280
- return (
281
- err instanceof SparkVaultMobileError &&
282
- (err.statusCode === 401 || err.statusCode === 403) &&
283
- err.details?.reason === 'VAT_EXPIRED'
284
- );
370
+ return vatRefusalReason(err) === VAT_REFUSAL_REASONS.EXPIRED;
285
371
  }
286
372
 
287
373
  /**
288
- * True when the vault access token is valid but read-only
289
- * (`details.reason === 'VAT_READ_ONLY'` on a 403): the session was unsealed
290
- * with a read-only access level, so a write cannot succeed by retrying or by
291
- * re-unsealing the same way — the caller needs write access to the vault.
374
+ * True when the vault access token is valid but read-only (403
375
+ * `VAT_READ_ONLY`): the session was unsealed with a read-only access level, so
376
+ * a write cannot succeed by retrying or by re-unsealing the same way — the
377
+ * caller needs write access to the vault.
292
378
  */
293
379
  export function isReadOnlyVaultError(err: unknown): err is SparkVaultMobileError {
294
- return (
295
- err instanceof SparkVaultMobileError &&
296
- err.statusCode === 403 &&
297
- err.details?.reason === 'VAT_READ_ONLY'
298
- );
380
+ return vatRefusalReason(err) === VAT_REFUSAL_REASONS.READ_ONLY;
381
+ }
382
+
383
+ /**
384
+ * True for the API's 404 not-found answer: the resource it names does not
385
+ * exist (or is not this caller's to see). The only answer that says a vault,
386
+ * an ingot or a key's vault is missing; a rate limit, an outage or a refused
387
+ * session says nothing about it. Never true for a `TusUploadError`: Forge's
388
+ * 404 is an upload session that is gone, not a missing resource.
389
+ */
390
+ export function isNotFoundError(err: unknown): err is SparkVaultMobileError {
391
+ return err instanceof SparkVaultMobileError && !(err instanceof TusUploadError) && err.statusCode === 404;
299
392
  }
300
393
 
301
394
  /** True for a 429 rate-limit response — retry after a cooldown, never immediately. */
@@ -311,18 +404,22 @@ export function isIngotNotReadyError(err: unknown): err is IngotNotReadyError {
311
404
  return err instanceof IngotNotReadyError;
312
405
  }
313
406
 
407
+ /** Type guard for a create refused over the same-name ingot holding its name. */
408
+ export function isIngotNameHeldError(err: unknown): err is IngotNameHeldError {
409
+ return err instanceof IngotNameHeldError;
410
+ }
411
+
314
412
  /**
315
- * True when retrying a failed download can succeed. Validation failures and
316
- * deterministic 4xx responses always fail the same way again; everything else
317
- * (connectivity, 5xx, 408, 429) is worth another attempt.
413
+ * True when retrying a failed download can succeed. A validation failure always
414
+ * fails the same way again, and an HTTP refusal is retried only when the
415
+ * platform's retry rule (`isTransientHttpStatus`, mirrored from
416
+ * `@sparkvault/app-sdk/transient`) calls it transient; every other failure
417
+ * (connectivity, an answer that could not be read) is worth another attempt.
318
418
  */
319
419
  export function isRetryableDownloadError(err: unknown): boolean {
320
420
  if (err instanceof SparkVaultValidationError) return false;
321
421
  const status = err instanceof SparkVaultMobileError ? err.statusCode : undefined;
322
- if (typeof status === 'number' && status >= 400 && status < 500 && status !== 408 && status !== 429) {
323
- return false;
324
- }
325
- return true;
422
+ return typeof status !== 'number' || status < 400 || isTransientHttpStatus(status);
326
423
  }
327
424
 
328
425
  /**
package/src/folders.ts CHANGED
@@ -7,6 +7,7 @@ import type {
7
7
  UpdateFolderRequest,
8
8
  } from './types.js';
9
9
  import { validateFolderId, validateIngotId, validateVaultId } from './validation.js';
10
+ import { apiPath } from './request-path.js';
10
11
 
11
12
  export class MobileFoldersClient {
12
13
  private readonly http: MobileHttpClient;
@@ -17,14 +18,14 @@ export class MobileFoldersClient {
17
18
 
18
19
  async list(vaultId: string, vat: string): Promise<ListFoldersResponse> {
19
20
  validateVaultId(vaultId);
20
- const response = await this.http.get<ListFoldersResponse>(`/vaults/${vaultId}/folders`, { vat });
21
+ const response = await this.http.get<ListFoldersResponse>(apiPath`/vaults/${vaultId}/folders`, { vat });
21
22
  return response.data;
22
23
  }
23
24
 
24
25
  async get(vaultId: string, folderId: string, vat: string): Promise<Folder> {
25
26
  validateVaultId(vaultId);
26
27
  validateFolderId(folderId);
27
- const response = await this.http.get<Folder>(`/vaults/${vaultId}/folders/${folderId}`, { vat });
28
+ const response = await this.http.get<Folder>(apiPath`/vaults/${vaultId}/folders/${folderId}`, { vat });
28
29
  return response.data;
29
30
  }
30
31
 
@@ -32,7 +33,7 @@ export class MobileFoldersClient {
32
33
  validateVaultId(vaultId);
33
34
  validateFolderId(folderId);
34
35
  const response = await this.http.get<Folder & { breadcrumb?: FolderBreadcrumbItem[] }>(
35
- `/vaults/${vaultId}/folders/${folderId}`,
36
+ apiPath`/vaults/${vaultId}/folders/${folderId}`,
36
37
  { vat }
37
38
  );
38
39
  return response.data.breadcrumb ?? [];
@@ -40,14 +41,14 @@ export class MobileFoldersClient {
40
41
 
41
42
  async create(vaultId: string, vat: string, data: CreateFolderRequest): Promise<Folder> {
42
43
  validateVaultId(vaultId);
43
- const response = await this.http.post<Folder>(`/vaults/${vaultId}/folders`, data, { vat });
44
+ const response = await this.http.post<Folder>(apiPath`/vaults/${vaultId}/folders`, data, { vat });
44
45
  return response.data;
45
46
  }
46
47
 
47
48
  async update(vaultId: string, folderId: string, vat: string, data: UpdateFolderRequest): Promise<Folder> {
48
49
  validateVaultId(vaultId);
49
50
  validateFolderId(folderId);
50
- const response = await this.http.put<Folder>(`/vaults/${vaultId}/folders/${folderId}`, data, { vat });
51
+ const response = await this.http.put<Folder>(apiPath`/vaults/${vaultId}/folders/${folderId}`, data, { vat });
51
52
  return response.data;
52
53
  }
53
54
 
@@ -62,7 +63,7 @@ export class MobileFoldersClient {
62
63
  async delete(vaultId: string, folderId: string, vat: string): Promise<void> {
63
64
  validateVaultId(vaultId);
64
65
  validateFolderId(folderId);
65
- await this.http.delete(`/vaults/${vaultId}/folders/${folderId}`, { vat });
66
+ await this.http.delete(apiPath`/vaults/${vaultId}/folders/${folderId}`, { vat });
66
67
  }
67
68
 
68
69
  async moveIngot(vaultId: string, ingotId: string, vat: string, folderId: string | null): Promise<void> {
@@ -71,6 +72,6 @@ export class MobileFoldersClient {
71
72
  if (folderId !== null) {
72
73
  validateFolderId(folderId);
73
74
  }
74
- await this.http.patch(`/vaults/${vaultId}/ingots/${ingotId}`, { folder_id: folderId }, { vat });
75
+ await this.http.patch(apiPath`/vaults/${vaultId}/ingots/${ingotId}`, { folder_id: folderId }, { vat });
75
76
  }
76
77
  }