@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/http.ts CHANGED
@@ -5,8 +5,11 @@ import {
5
5
  SparkVaultMobileError,
6
6
  SparkVaultNetworkError,
7
7
  SparkVaultTimeoutError,
8
+ vatRefusalReason,
8
9
  } from './errors.js';
10
+ import { isTransientHttpStatus } from './app-sdk/transient.js';
9
11
  import { createDedupMutex } from './mutex.js';
12
+ import { queryString, type ApiPath, type QueryParams } from './request-path.js';
10
13
  import type { ApiErrorBody, ApiMeta, ApiResponse, RateLimitInfo } from './types.js';
11
14
 
12
15
  export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
@@ -14,6 +17,8 @@ export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
14
17
  export interface RequestOptions {
15
18
  body?: unknown;
16
19
  headers?: Record<string, string>;
20
+ /** Query parameters appended to the path; empty values are left out. */
21
+ query?: QueryParams;
17
22
  skipAuth?: boolean;
18
23
  vat?: string;
19
24
  timeoutMs?: number;
@@ -22,6 +27,21 @@ export interface RequestOptions {
22
27
  export type AuthEvent = 'logout' | 'token_refreshed';
23
28
  export type AuthEventListener = (event: AuthEvent) => void;
24
29
 
30
+ /**
31
+ * How a token refresh ended.
32
+ * - `refreshed`: a new access token is stored.
33
+ * - `rejected`: the session is over - no refresh token is stored, or the
34
+ * server refused it (the stored pair is then cleared and 'logout' emitted).
35
+ * - `transient`: the refresh could not complete (connectivity, timeout, a
36
+ * 408/429/5xx answer, a token-store failure). The session stands, and
37
+ * `error` is what the caller surfaces: it must never read as an
38
+ * authentication failure, which callers treat as a rejected credential.
39
+ */
40
+ export type RefreshOutcome =
41
+ | { status: 'refreshed' }
42
+ | { status: 'rejected' }
43
+ | { status: 'transient'; error: unknown };
44
+
25
45
  function parseRetryAfter(headers: Headers): number | null {
26
46
  const retryAfter = headers.get('Retry-After');
27
47
  if (!retryAfter) return null;
@@ -50,7 +70,7 @@ function isJsonBody(body: unknown): body is Record<string, unknown> | unknown[]
50
70
  export class MobileHttpClient {
51
71
  private readonly config: ResolvedMobileConfig;
52
72
  private readonly authListeners = new Set<AuthEventListener>();
53
- private readonly refreshMutex = createDedupMutex<boolean>();
73
+ private readonly refreshMutex = createDedupMutex<RefreshOutcome>();
54
74
  private lastMeta: ApiMeta | null = null;
55
75
  private rateLimitInfo: RateLimitInfo | null = null;
56
76
 
@@ -139,17 +159,14 @@ export class MobileHttpClient {
139
159
  }
140
160
  }
141
161
 
142
- private normalizeError(err: unknown, isUpload = false): never {
162
+ /** The SDK error for a request the fetch adapter rejected (no HTTP response). */
163
+ private transportError(err: unknown): SparkVaultMobileError {
143
164
  if (err instanceof SparkVaultMobileError) {
144
- throw err;
165
+ return err;
145
166
  }
146
167
 
147
168
  if (err instanceof Error && (err.name === 'AbortError' || err.name === 'TimeoutError')) {
148
- throw new SparkVaultTimeoutError(
149
- isUpload
150
- ? 'Upload timed out. Please check your connection and try again.'
151
- : 'Request timed out. Please check your connection and try again.'
152
- );
169
+ return new SparkVaultTimeoutError('Request timed out. Please check your connection and try again.');
153
170
  }
154
171
 
155
172
  // Connectivity failures arrive in two shapes depending on the injected
@@ -158,29 +175,46 @@ export class MobileHttpClient {
158
175
  // normalize to SparkVaultNetworkError or consumers misclassify an offline
159
176
  // failure as a generic (non-retryable) error.
160
177
  if (err instanceof TypeError || (err instanceof Error && err.name === 'NetworkError')) {
161
- throw new SparkVaultNetworkError('Network request failed. Please check your connection.');
178
+ return new SparkVaultNetworkError('Network request failed. Please check your connection.');
162
179
  }
163
180
 
164
- throw new SparkVaultMobileError(err instanceof Error ? err.message : 'Unknown request error');
181
+ return new SparkVaultMobileError(err instanceof Error ? err.message : 'Unknown request error');
165
182
  }
166
183
 
167
184
  /**
168
- * Single exit for non-OK responses: 402s become typed gate errors
185
+ * Single mapping for non-OK responses: 402s become typed gate errors
169
186
  * (PlanRequiredError / QuotaExceededError) so the subscribe / add-on UX keys
170
187
  * off one set of types; everything else becomes a SparkVaultMobileError
171
188
  * carrying the server code, status, details, and meta.
172
189
  */
173
- private throwHttpError(status: number, errorBody: ApiErrorBody): never {
174
- if (errorBody.meta) {
175
- this.lastMeta = errorBody.meta;
190
+ /** An error response's JSON body, or an empty one when it has none. */
191
+ private async readErrorBody(response: Response): Promise<ApiErrorBody> {
192
+ try {
193
+ return await response.json();
194
+ } catch {
195
+ this.config.logger.debug('Failed to parse error response body', { status: response.status });
196
+ return {};
197
+ }
198
+ }
199
+
200
+ /**
201
+ * A 401 that names a refused vault access token is not a dead login: no
202
+ * refresh revives a VAT, so it is thrown as it is instead of spending the
203
+ * one-time refresh token.
204
+ */
205
+ private async throwIfVatRefused(response: Response): Promise<void> {
206
+ const error = this.httpError(response.status, await this.readErrorBody(response));
207
+ if (vatRefusalReason(error) !== null) {
208
+ throw error;
176
209
  }
210
+ }
177
211
 
178
- const gateError = gateErrorFromBody(status, errorBody);
179
- if (gateError) {
180
- throw gateError;
212
+ private httpError(status: number, errorBody: ApiErrorBody): SparkVaultMobileError {
213
+ if (errorBody.meta) {
214
+ this.lastMeta = errorBody.meta;
181
215
  }
182
216
 
183
- throw new SparkVaultMobileError(
217
+ return gateErrorFromBody(status, errorBody) ?? new SparkVaultMobileError(
184
218
  errorBody.error?.message ?? errorBody.message ?? this.defaultErrorMessage(status),
185
219
  {
186
220
  code: errorBody.error?.code ?? status,
@@ -191,11 +225,8 @@ export class MobileHttpClient {
191
225
  );
192
226
  }
193
227
 
194
- private makeUrl(endpoint: string): string {
195
- if (/^https?:\/\//i.test(endpoint)) {
196
- return endpoint;
197
- }
198
- return `${this.config.apiBaseUrl}${endpoint}`;
228
+ private makeUrl(path: ApiPath, query: QueryParams | undefined): string {
229
+ return `${this.config.apiBaseUrl}${path}${queryString(query)}`;
199
230
  }
200
231
 
201
232
  private serializeBody(body: unknown): BodyInit | undefined {
@@ -203,58 +234,61 @@ export class MobileHttpClient {
203
234
  return isJsonBody(body) ? JSON.stringify(body) : body as BodyInit;
204
235
  }
205
236
 
206
- async refreshAccessToken(): Promise<boolean> {
207
- try {
208
- return await this.refreshMutex.dedupe(() => this.doRefreshOnce());
209
- } catch {
210
- this.refreshMutex.reset();
211
- return false;
212
- }
237
+ /**
238
+ * Refresh the access token once; concurrent callers share the one attempt
239
+ * (refresh tokens rotate and are one-time-use). Never throws: every failure
240
+ * is an outcome.
241
+ */
242
+ refreshAccessToken(): Promise<RefreshOutcome> {
243
+ return this.refreshMutex.dedupe(() => this.doRefreshOnce());
213
244
  }
214
245
 
215
- private async doRefreshOnce(): Promise<boolean> {
216
- const refreshToken = await this.config.tokenStorage.getRefreshToken();
217
- if (!refreshToken) return false;
218
-
246
+ private async doRefreshOnce(): Promise<RefreshOutcome> {
219
247
  try {
248
+ const refreshToken = await this.config.tokenStorage.getRefreshToken();
249
+ if (!refreshToken) return { status: 'rejected' };
220
250
  return await this.doRefresh(refreshToken);
221
251
  } catch (err) {
222
- const error = err instanceof Error ? err : new Error(String(err));
223
- this.config.logger.warn('Token refresh failed without retry', { error: error.message });
224
- return false;
252
+ // A token store that could not be read or written: the session was not
253
+ // refused, so it stands, and the store's own error is what the caller
254
+ // sees (as it is when the store fails before any request).
255
+ this.config.logger.warn('Token refresh could not use the token store', {
256
+ error: err instanceof Error ? err.message : String(err),
257
+ });
258
+ return { status: 'transient', error: err };
225
259
  }
226
260
  }
227
261
 
228
- private async doRefresh(refreshToken: string): Promise<boolean> {
229
- const response = await this.config.fetch(`${this.config.apiBaseUrl}/auth/refresh`, {
230
- method: 'POST',
231
- headers: {
232
- 'Content-Type': 'application/json',
233
- Accept: 'application/json',
234
- 'User-Agent': this.config.userAgent,
235
- },
236
- body: JSON.stringify({ refresh_token: refreshToken }),
237
- timeoutMs: this.config.timeoutMs,
238
- });
262
+ private async doRefresh(refreshToken: string): Promise<RefreshOutcome> {
263
+ let response: Response;
264
+ try {
265
+ response = await this.config.fetch(`${this.config.apiBaseUrl}/auth/refresh`, {
266
+ method: 'POST',
267
+ headers: {
268
+ 'Content-Type': 'application/json',
269
+ Accept: 'application/json',
270
+ 'User-Agent': this.config.userAgent,
271
+ },
272
+ body: JSON.stringify({ refresh_token: refreshToken }),
273
+ timeoutMs: this.config.timeoutMs,
274
+ });
275
+ } catch (err) {
276
+ const error = this.transportError(err);
277
+ this.config.logger.warn('Token refresh did not reach the API', { error: error.message });
278
+ return { status: 'transient', error };
279
+ }
239
280
 
240
281
  if (!response.ok) {
241
- if (response.status === 429) {
242
- this.config.logger.warn('Token refresh rate limited', {
243
- retryAfterMs: parseRetryAfter(response.headers),
244
- });
245
- return false;
246
- }
247
-
248
- if (response.status >= 500 || response.status === 408) {
282
+ if (isTransientHttpStatus(response.status)) {
249
283
  this.config.logger.warn('Token refresh failed with transient HTTP status', {
250
284
  status: response.status,
285
+ retryAfterMs: parseRetryAfter(response.headers),
251
286
  });
252
- return false;
287
+ const errorBody: ApiErrorBody = await response.json().catch(() => ({}));
288
+ return { status: 'transient', error: this.httpError(response.status, errorBody) };
253
289
  }
254
290
 
255
- await this.config.tokenStorage.clearTokens();
256
- this.emitAuthEvent('logout');
257
- return false;
291
+ return this.endRefusedSession();
258
292
  }
259
293
 
260
294
  const body = await response.json().catch(() => ({} as ApiResponse<{
@@ -268,9 +302,7 @@ export class MobileHttpClient {
268
302
  hasData: Boolean(data),
269
303
  accessTokenType: typeof data?.access_token,
270
304
  });
271
- await this.config.tokenStorage.clearTokens();
272
- this.emitAuthEvent('logout');
273
- return false;
305
+ return this.endRefusedSession();
274
306
  }
275
307
 
276
308
  // Refresh tokens rotate and are one-time-use: persist the pair through a
@@ -289,11 +321,43 @@ export class MobileHttpClient {
289
321
  await this.config.tokenStorage.setAccessToken(data.access_token);
290
322
  }
291
323
  this.emitAuthEvent('token_refreshed');
292
- return true;
324
+ return { status: 'refreshed' };
325
+ }
326
+
327
+ /**
328
+ * The server refused the session: drop the stored pair and end it. The
329
+ * session is over whether or not the local clear lands, so 'logout' always
330
+ * fires and the app's teardown runs.
331
+ */
332
+ private async endRefusedSession(): Promise<RefreshOutcome> {
333
+ try {
334
+ await this.config.tokenStorage.clearTokens();
335
+ } catch (err) {
336
+ this.config.logger.warn('Could not clear the refused session tokens', {
337
+ error: err instanceof Error ? err.message : String(err),
338
+ });
339
+ }
340
+ this.emitAuthEvent('logout');
341
+ return { status: 'rejected' };
342
+ }
343
+
344
+ /**
345
+ * Renew the session after a 401, or throw what the caller should see:
346
+ * SparkVaultAuthenticationError only when the session is over, otherwise
347
+ * the transient failure that kept the refresh from completing.
348
+ */
349
+ private async renewSession(): Promise<void> {
350
+ const outcome = await this.refreshAccessToken();
351
+ if (outcome.status === 'rejected') {
352
+ throw new SparkVaultAuthenticationError('Session expired. Please log in again.');
353
+ }
354
+ if (outcome.status === 'transient') {
355
+ throw outcome.error;
356
+ }
293
357
  }
294
358
 
295
- async request<T>(method: HttpMethod, endpoint: string, options: RequestOptions = {}): Promise<ApiResponse<T>> {
296
- const url = this.makeUrl(endpoint);
359
+ async request<T>(method: HttpMethod, path: ApiPath, options: RequestOptions = {}): Promise<ApiResponse<T>> {
360
+ const url = this.makeUrl(path, options.query);
297
361
  const timeoutMs = options.timeoutMs ?? this.config.timeoutMs;
298
362
  const body = method === 'GET' ? undefined : this.serializeBody(options.body);
299
363
  let headers = await this.getHeaders({ ...options, body: options.body });
@@ -302,20 +366,18 @@ export class MobileHttpClient {
302
366
  try {
303
367
  response = await this.config.fetch(url, { method, headers, body, timeoutMs });
304
368
  } catch (err) {
305
- this.normalizeError(err);
369
+ throw this.transportError(err);
306
370
  }
307
371
  this.parseRateLimitHeaders(response.headers);
308
372
 
309
373
  if (response.status === 401 && !options.skipAuth) {
310
- const refreshed = await this.refreshAccessToken();
311
- if (!refreshed) {
312
- throw new SparkVaultAuthenticationError('Session expired. Please log in again.');
313
- }
374
+ await this.throwIfVatRefused(response);
375
+ await this.renewSession();
314
376
  headers = await this.getHeaders({ ...options, body: options.body });
315
377
  try {
316
378
  response = await this.config.fetch(url, { method, headers, body, timeoutMs });
317
379
  } catch (err) {
318
- this.normalizeError(err);
380
+ throw this.transportError(err);
319
381
  }
320
382
  this.parseRateLimitHeaders(response.headers);
321
383
  }
@@ -331,14 +393,7 @@ export class MobileHttpClient {
331
393
  }
332
394
 
333
395
  if (!response.ok) {
334
- let errorBody: ApiErrorBody = {};
335
- try {
336
- errorBody = await response.json();
337
- } catch {
338
- this.config.logger.debug('Failed to parse error response body', { status: response.status });
339
- }
340
-
341
- this.throwHttpError(response.status, errorBody);
396
+ throw this.httpError(response.status, await this.readErrorBody(response));
342
397
  }
343
398
 
344
399
  const result = await response.json();
@@ -349,84 +404,24 @@ export class MobileHttpClient {
349
404
  return result as ApiResponse<T>;
350
405
  }
351
406
 
352
- async requestRaw(endpoint: string, options: RequestOptions & { method?: HttpMethod; responseType?: XMLHttpRequestResponseType } = {}): Promise<Response> {
353
- const method = options.method ?? 'GET';
354
- const url = this.makeUrl(endpoint);
355
- const timeoutMs = options.timeoutMs ?? this.config.fileTransferTimeoutMs;
356
- const body = method === 'GET' ? undefined : this.serializeBody(options.body);
357
- let headers = await this.getHeaders({ ...options, body: options.body });
358
-
359
- let response: Response;
360
- try {
361
- response = await this.config.fetch(url, {
362
- method,
363
- headers,
364
- body,
365
- timeoutMs,
366
- responseType: options.responseType,
367
- });
368
- } catch (err) {
369
- this.normalizeError(err, true);
370
- }
371
-
372
- if (response.status === 401 && !options.skipAuth) {
373
- const refreshed = await this.refreshAccessToken();
374
- if (!refreshed) {
375
- throw new SparkVaultAuthenticationError('Session expired. Please log in again.');
376
- }
377
- headers = await this.getHeaders({ ...options, body: options.body });
378
- try {
379
- response = await this.config.fetch(url, {
380
- method,
381
- headers,
382
- body,
383
- timeoutMs,
384
- responseType: options.responseType,
385
- });
386
- } catch (err) {
387
- this.normalizeError(err, true);
388
- }
389
- }
390
-
391
- if (!response.ok) {
392
- let errorBody: ApiErrorBody = {};
393
- try {
394
- errorBody = await response.json();
395
- } catch {
396
- this.config.logger.debug('Failed to parse raw error response body', { status: response.status });
397
- }
398
- this.throwHttpError(response.status, errorBody);
399
- }
400
-
401
- return response;
402
- }
403
-
404
- get<T>(endpoint: string, options?: RequestOptions): Promise<ApiResponse<T>> {
405
- return this.request<T>('GET', endpoint, options);
406
- }
407
-
408
- post<T>(endpoint: string, body?: unknown, options?: RequestOptions): Promise<ApiResponse<T>> {
409
- return this.request<T>('POST', endpoint, { ...options, body });
407
+ get<T>(path: ApiPath, options?: RequestOptions): Promise<ApiResponse<T>> {
408
+ return this.request<T>('GET', path, options);
410
409
  }
411
410
 
412
- put<T>(endpoint: string, body?: unknown, options?: RequestOptions): Promise<ApiResponse<T>> {
413
- return this.request<T>('PUT', endpoint, { ...options, body });
411
+ post<T>(path: ApiPath, body?: unknown, options?: RequestOptions): Promise<ApiResponse<T>> {
412
+ return this.request<T>('POST', path, { ...options, body });
414
413
  }
415
414
 
416
- patch<T>(endpoint: string, body?: unknown, options?: RequestOptions): Promise<ApiResponse<T>> {
417
- return this.request<T>('PATCH', endpoint, { ...options, body });
415
+ put<T>(path: ApiPath, body?: unknown, options?: RequestOptions): Promise<ApiResponse<T>> {
416
+ return this.request<T>('PUT', path, { ...options, body });
418
417
  }
419
418
 
420
- delete<T>(endpoint: string, options?: RequestOptions): Promise<ApiResponse<T>> {
421
- return this.request<T>('DELETE', endpoint, options);
419
+ patch<T>(path: ApiPath, body?: unknown, options?: RequestOptions): Promise<ApiResponse<T>> {
420
+ return this.request<T>('PATCH', path, { ...options, body });
422
421
  }
423
422
 
424
- async uploadFile<T>(
425
- endpoint: string,
426
- formData: FormData,
427
- options: Omit<RequestOptions, 'body'> = {}
428
- ): Promise<ApiResponse<T>> {
429
- return this.request<T>('POST', endpoint, { ...options, body: formData, timeoutMs: options.timeoutMs ?? this.config.fileTransferTimeoutMs });
423
+ delete<T>(path: ApiPath, options?: RequestOptions): Promise<ApiResponse<T>> {
424
+ return this.request<T>('DELETE', path, options);
430
425
  }
431
426
 
432
427
  private defaultErrorMessage(status: number): string {
@@ -13,6 +13,7 @@ import {
13
13
  View,
14
14
  } from 'react-native';
15
15
  import type { SparkVaultMobile } from './client.js';
16
+ import { identityKeyboardType, inferIdentityType, parseIdentity } from './identity-input.js';
16
17
  import type {
17
18
  IdentityConfig,
18
19
  IdentityJwks,
@@ -43,8 +44,6 @@ export interface SparkVaultIdentityDialogProps {
43
44
  * for a host that drives the ceremony itself.
44
45
  */
45
46
  const SUPPORTED_METHODS = new Set<IdentityMethodId>(['otp_email', 'otp_sms', 'otp_voice']);
46
- const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
47
- const E164_PHONE_REGEX = /^\+[1-9]\d{7,14}$/;
48
47
 
49
48
  export function SparkVaultIdentityDialog({
50
49
  client,
@@ -385,7 +384,7 @@ export function SparkVaultIdentityDialog({
385
384
  setIdentity(value);
386
385
  setError('');
387
386
  if (allowedTypes.length > 1) {
388
- setIdentityType(value.trim().startsWith('+') || /^\d/.test(value.trim()) ? 'phone' : 'email');
387
+ setIdentityType(inferIdentityType(value));
389
388
  }
390
389
  }}
391
390
  placeholder={identityPlaceholder(allowedTypes)}
@@ -393,7 +392,7 @@ export function SparkVaultIdentityDialog({
393
392
  autoCapitalize="none"
394
393
  autoCorrect={false}
395
394
  autoComplete={identityType === 'phone' ? 'tel' : 'email'}
396
- keyboardType={identityType === 'phone' ? 'phone-pad' : 'email-address'}
395
+ keyboardType={identityKeyboardType(allowedTypes)}
397
396
  editable={!isBusy}
398
397
  style={[
399
398
  styles.input,
@@ -587,7 +586,7 @@ function resolveInitialIdentityType(
587
586
  allowedTypes: IdentityType[]
588
587
  ): IdentityType {
589
588
  if (explicitType && allowedTypes.includes(explicitType)) return explicitType;
590
- if (identity?.trim().startsWith('+') && allowedTypes.includes('phone')) return 'phone';
589
+ if (inferIdentityType(identity ?? '') === 'phone' && allowedTypes.includes('phone')) return 'phone';
591
590
  return allowedTypes.includes('email') ? 'email' : 'phone';
592
591
  }
593
592
 
@@ -603,38 +602,6 @@ function getMethodsForIdentity(
603
602
  });
604
603
  }
605
604
 
606
- function parseIdentity(
607
- value: string,
608
- allowedTypes: IdentityType[]
609
- ): { ok: true; identity: string; identityType: IdentityType } | { ok: false; error: string } {
610
- const trimmed = value.trim();
611
- if (!trimmed) {
612
- return { ok: false, error: 'Enter an identity to continue.' };
613
- }
614
-
615
- const inferredType: IdentityType = trimmed.startsWith('+') || /^\d/.test(trimmed) ? 'phone' : 'email';
616
- if (!allowedTypes.includes(inferredType)) {
617
- return {
618
- ok: false,
619
- error: inferredType === 'phone' ? 'Phone sign-in is not enabled for this app.' : 'Email sign-in is not enabled for this app.',
620
- };
621
- }
622
-
623
- if (inferredType === 'email') {
624
- const email = trimmed.toLowerCase();
625
- if (!EMAIL_REGEX.test(email) || email.length > 254) {
626
- return { ok: false, error: 'Enter a valid email address.' };
627
- }
628
- return { ok: true, identity: email, identityType: 'email' };
629
- }
630
-
631
- const phone = trimmed.startsWith('+') ? trimmed.replace(/[^\d+]/g, '') : `+${trimmed.replace(/\D/g, '')}`;
632
- if (!E164_PHONE_REGEX.test(phone)) {
633
- return { ok: false, error: 'Enter a valid phone number in international format.' };
634
- }
635
- return { ok: true, identity: phone, identityType: 'phone' };
636
- }
637
-
638
605
  function toOtpMethod(method: SupportedMethodId): OtpMethod {
639
606
  if (method === 'otp_sms') return 'sms';
640
607
  if (method === 'otp_voice') return 'voice';
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Identity entry rules: what a typed identity is, its normalized form, and the
3
+ * keyboard its field needs. The sign-in dialog and every public identity method
4
+ * read identities through these rules, so a number or address resolves to the
5
+ * same identity whichever path it arrives by. A rule that misreads an identity
6
+ * locks that person out.
7
+ *
8
+ * What reads as a phone number and its E.164 form, and what is an email address
9
+ * and its canonical form, are the platform's one rules, mirrored from
10
+ * `@sparkvault/app-sdk/phone` and `@sparkvault/app-sdk/email-address` into
11
+ * `./app-sdk/`: the rules the Identity Product and the web SDK's identity field
12
+ * apply, so the dialog refuses exactly what the server would.
13
+ */
14
+
15
+ import { canonicalEmailAddress, isValidEmailAddress } from './app-sdk/email-address.js';
16
+ import { inferIdentityType, parsePhone } from './app-sdk/phone.js';
17
+ import type { IdentityType } from './types.js';
18
+
19
+ export { inferIdentityType };
20
+
21
+ /**
22
+ * An identity in the form the Identity Product keys it by, read as the given
23
+ * type: the canonical email address (trimmed, lowercased), or a phone number in
24
+ * E.164. Null when the value is not a valid identity of that type.
25
+ */
26
+ export function normalizeIdentifier(value: string, identityType: IdentityType): string | null {
27
+ if (identityType === 'phone') {
28
+ return parsePhone(value);
29
+ }
30
+ return isValidEmailAddress(value) ? canonicalEmailAddress(value) : null;
31
+ }
32
+
33
+ export type ParsedIdentity =
34
+ | { ok: true; identity: string; identityType: IdentityType }
35
+ | { ok: false; error: string };
36
+
37
+ /** Classify and normalize a submitted identity (the canonical email address, or E.164 phone via `parsePhone`). */
38
+ export function parseIdentity(value: string, allowedTypes: IdentityType[]): ParsedIdentity {
39
+ const trimmed = value.trim();
40
+ if (!trimmed) {
41
+ return { ok: false, error: 'Enter an identity to continue.' };
42
+ }
43
+
44
+ const inferredType = inferIdentityType(trimmed);
45
+ if (!allowedTypes.includes(inferredType)) {
46
+ return {
47
+ ok: false,
48
+ error: inferredType === 'phone' ? 'Phone sign-in is not enabled for this app.' : 'Email sign-in is not enabled for this app.',
49
+ };
50
+ }
51
+
52
+ const identity = normalizeIdentifier(trimmed, inferredType);
53
+ if (!identity) {
54
+ return {
55
+ ok: false,
56
+ error: inferredType === 'email' ? 'Enter a valid email address.' : 'Enter a valid phone number in international format.',
57
+ };
58
+ }
59
+ return { ok: true, identity, identityType: inferredType };
60
+ }
61
+
62
+ /**
63
+ * The identity field's keyboard. A field that takes an email keeps a keyboard
64
+ * that can type one even while the input still reads as a phone number: the
65
+ * first characters of "2024user@gmail.com" do, and the phone pad has no
66
+ * letters or '@' to finish it with.
67
+ */
68
+ export function identityKeyboardType(allowedTypes: IdentityType[]): 'email-address' | 'phone-pad' {
69
+ return allowedTypes.includes('email') ? 'email-address' : 'phone-pad';
70
+ }
package/src/index.ts CHANGED
@@ -3,6 +3,7 @@ export {
3
3
  createSparkVaultMobileClient,
4
4
  } from './client.js';
5
5
  export type {
6
+ MobileConnectivity,
6
7
  SparkVaultMobileConfig,
7
8
  ResolvedMobileConfig,
8
9
  } from './config.js';
@@ -25,8 +26,10 @@ export {
25
26
  } from './folders.js';
26
27
  export {
27
28
  MobileIngotsClient,
29
+ NAME_CONFLICT_POLICIES,
28
30
  } from './ingots.js';
29
31
  export type {
32
+ NameConflictPolicy,
30
33
  DownloadToFileOptions,
31
34
  DownloadToFileResult,
32
35
  UploadOptions,
@@ -86,13 +89,11 @@ export type {
86
89
  export {
87
90
  MobileBillingClient,
88
91
  } from './billing.js';
89
- export type {
90
- PortalSession,
91
- } from './billing.js';
92
92
  export {
93
93
  MobileUsersClient,
94
94
  } from './users.js';
95
95
  export {
96
+ isSessionExpired,
96
97
  isSessionGone,
97
98
  MobileTusUploader,
98
99
  parseForgeUrl,
@@ -110,20 +111,25 @@ export {
110
111
  bytesToUtf8,
111
112
  base64ToBytes,
112
113
  bytesToBase64,
114
+ bytesToBase64url,
113
115
  utf8ToBytes,
114
116
  } from './encoding.js';
115
117
  export {
116
118
  BackgroundTransferError,
117
119
  gateErrorFromBody,
120
+ IngotNameHeldError,
118
121
  IngotNotReadyError,
119
122
  isAuthError,
123
+ isIngotNameHeldError,
120
124
  isIngotNotReadyError,
121
125
  isNetworkError,
126
+ isNotFoundError,
122
127
  isRateLimitError,
123
128
  isReadOnlyVaultError,
124
129
  isRetryableDownloadError,
125
130
  isVatExpiredError,
126
131
  PlanRequiredError,
132
+ vatRefusalReason,
127
133
  QuotaExceededError,
128
134
  SparkVaultAuthenticationError,
129
135
  SparkVaultAuthorizationError,
@@ -133,6 +139,10 @@ export {
133
139
  SparkVaultValidationError,
134
140
  TusUploadError,
135
141
  } from './errors.js';
142
+ export { VAT_REFUSAL_REASONS, endsVatSession } from './app-sdk/vat-refusal.js';
143
+ export type {
144
+ IngotNameHolder,
145
+ } from './errors.js';
136
146
  export {
137
147
  isSafeId,
138
148
  sanitizeFilename,