@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.
- package/README.md +5 -5
- package/dist/account.d.ts +4 -3
- package/dist/account.js +16 -11
- package/dist/account.js.map +1 -1
- package/dist/app-sdk/email-address.d.ts +37 -0
- package/dist/app-sdk/email-address.js +91 -0
- package/dist/app-sdk/email-address.js.map +1 -0
- package/dist/app-sdk/encoding.d.ts +64 -0
- package/dist/app-sdk/encoding.js +245 -0
- package/dist/app-sdk/encoding.js.map +1 -0
- package/dist/app-sdk/entropy-contract.d.ts +33 -0
- package/dist/app-sdk/entropy-contract.js +51 -0
- package/dist/app-sdk/entropy-contract.js.map +1 -0
- package/dist/app-sdk/filename.d.ts +93 -0
- package/dist/app-sdk/filename.js +161 -0
- package/dist/app-sdk/filename.js.map +1 -0
- package/dist/app-sdk/forge-url.d.ts +107 -0
- package/dist/app-sdk/forge-url.js +222 -0
- package/dist/app-sdk/forge-url.js.map +1 -0
- package/dist/app-sdk/ids.d.ts +48 -0
- package/dist/app-sdk/ids.js +65 -0
- package/dist/app-sdk/ids.js.map +1 -0
- package/dist/app-sdk/ingot-access-log.d.ts +29 -0
- package/dist/app-sdk/ingot-access-log.js +49 -0
- package/dist/app-sdk/ingot-access-log.js.map +1 -0
- package/dist/app-sdk/phone.d.ts +37 -0
- package/dist/app-sdk/phone.js +134 -0
- package/dist/app-sdk/phone.js.map +1 -0
- package/dist/app-sdk/platform-domains.d.ts +41 -0
- package/dist/app-sdk/platform-domains.js +82 -0
- package/dist/app-sdk/platform-domains.js.map +1 -0
- package/dist/app-sdk/request-path.d.ts +49 -0
- package/dist/app-sdk/request-path.js +81 -0
- package/dist/app-sdk/request-path.js.map +1 -0
- package/dist/app-sdk/transfer-retry.d.ts +298 -0
- package/dist/app-sdk/transfer-retry.js +535 -0
- package/dist/app-sdk/transfer-retry.js.map +1 -0
- package/dist/app-sdk/transient.d.ts +23 -0
- package/dist/app-sdk/transient.js +29 -0
- package/dist/app-sdk/transient.js.map +1 -0
- package/dist/app-sdk/upload-publication.d.ts +81 -0
- package/dist/app-sdk/upload-publication.js +93 -0
- package/dist/app-sdk/upload-publication.js.map +1 -0
- package/dist/app-sdk/vat-refusal.d.ts +66 -0
- package/dist/app-sdk/vat-refusal.js +69 -0
- package/dist/app-sdk/vat-refusal.js.map +1 -0
- package/dist/billing.d.ts +9 -17
- package/dist/billing.js +14 -17
- package/dist/billing.js.map +1 -1
- package/dist/config.d.ts +33 -1
- package/dist/config.js +23 -17
- package/dist/config.js.map +1 -1
- package/dist/encoding.d.ts +30 -4
- package/dist/encoding.js +89 -83
- package/dist/encoding.js.map +1 -1
- package/dist/entropy.d.ts +25 -4
- package/dist/entropy.js +62 -15
- package/dist/entropy.js.map +1 -1
- package/dist/entropy.test-d.d.ts +40 -0
- package/dist/entropy.test-d.js +12 -0
- package/dist/entropy.test-d.js.map +1 -0
- package/dist/errors.d.ts +105 -21
- package/dist/errors.js +74 -28
- package/dist/errors.js.map +1 -1
- package/dist/folders.js +8 -7
- package/dist/folders.js.map +1 -1
- package/dist/http.d.ts +57 -15
- package/dist/http.js +125 -138
- package/dist/http.js.map +1 -1
- package/dist/identity-dialog.js +4 -30
- package/dist/identity-dialog.js.map +1 -1
- package/dist/identity-input.d.ts +39 -0
- package/dist/identity-input.js +59 -0
- package/dist/identity-input.js.map +1 -0
- package/dist/index.d.ts +8 -7
- package/dist/index.js +5 -4
- package/dist/index.js.map +1 -1
- package/dist/ingots.d.ts +77 -32
- package/dist/ingots.js +152 -136
- package/dist/ingots.js.map +1 -1
- package/dist/mutex.d.ts +1 -2
- package/dist/mutex.js +0 -6
- package/dist/mutex.js.map +1 -1
- package/dist/notify.d.ts +7 -2
- package/dist/notify.js +12 -15
- package/dist/notify.js.map +1 -1
- package/dist/notify.test-d.d.ts +3 -2
- package/dist/notify.test-d.js +1 -1
- package/dist/products/identity.d.ts +5 -0
- package/dist/products/identity.js +40 -35
- package/dist/products/identity.js.map +1 -1
- package/dist/request-path.d.ts +32 -0
- package/dist/request-path.js +35 -0
- package/dist/request-path.js.map +1 -0
- package/dist/sparks.js +16 -15
- package/dist/sparks.js.map +1 -1
- package/dist/tus.d.ts +198 -16
- package/dist/tus.js +816 -327
- package/dist/tus.js.map +1 -1
- package/dist/types.d.ts +183 -23
- package/dist/users.d.ts +12 -5
- package/dist/users.js +27 -19
- package/dist/users.js.map +1 -1
- package/dist/validation.d.ts +20 -5
- package/dist/validation.js +29 -27
- package/dist/validation.js.map +1 -1
- package/dist/vaults.d.ts +34 -83
- package/dist/vaults.js +64 -44
- package/dist/vaults.js.map +1 -1
- package/dist/vaults.test-d.d.ts +22 -0
- package/dist/vaults.test-d.js +14 -0
- package/dist/vaults.test-d.js.map +1 -0
- package/package.json +4 -2
- package/src/account.ts +16 -11
- package/src/app-sdk/email-address.js +94 -0
- package/src/app-sdk/encoding.js +250 -0
- package/src/app-sdk/entropy-contract.js +56 -0
- package/src/app-sdk/filename.js +174 -0
- package/src/app-sdk/forge-url.js +224 -0
- package/src/app-sdk/ids.js +71 -0
- package/src/app-sdk/ingot-access-log.js +51 -0
- package/src/app-sdk/phone.js +139 -0
- package/src/app-sdk/platform-domains.js +99 -0
- package/src/app-sdk/request-path.js +81 -0
- package/src/app-sdk/transfer-retry.js +563 -0
- package/src/app-sdk/transient.js +30 -0
- package/src/app-sdk/upload-publication.js +102 -0
- package/src/app-sdk/vat-refusal.js +74 -0
- package/src/billing.ts +16 -25
- package/src/config.ts +56 -23
- package/src/encoding.ts +91 -103
- package/src/entropy.test-d.ts +47 -0
- package/src/entropy.ts +86 -20
- package/src/errors.ts +135 -38
- package/src/folders.ts +8 -7
- package/src/http.ts +145 -150
- package/src/identity-dialog.tsx +4 -37
- package/src/identity-input.ts +70 -0
- package/src/index.ts +13 -3
- package/src/ingots.ts +225 -180
- package/src/mutex.ts +1 -8
- package/src/notify.test-d.ts +3 -1
- package/src/notify.ts +19 -17
- package/src/products/identity.ts +45 -39
- package/src/request-path.ts +45 -0
- package/src/sparks.ts +16 -13
- package/src/tus.ts +1054 -408
- package/src/types.ts +181 -23
- package/src/users.ts +27 -17
- package/src/validation.ts +30 -24
- package/src/vaults.test-d.ts +29 -0
- package/src/vaults.ts +87 -153
- package/dist/platform-domains.generated.d.ts +0 -2
- package/dist/platform-domains.generated.js +0 -16
- package/dist/platform-domains.generated.js.map +0 -1
- 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<
|
|
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
|
-
|
|
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
|
-
|
|
165
|
+
return err;
|
|
145
166
|
}
|
|
146
167
|
|
|
147
168
|
if (err instanceof Error && (err.name === 'AbortError' || err.name === 'TimeoutError')) {
|
|
148
|
-
|
|
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
|
-
|
|
178
|
+
return new SparkVaultNetworkError('Network request failed. Please check your connection.');
|
|
162
179
|
}
|
|
163
180
|
|
|
164
|
-
|
|
181
|
+
return new SparkVaultMobileError(err instanceof Error ? err.message : 'Unknown request error');
|
|
165
182
|
}
|
|
166
183
|
|
|
167
184
|
/**
|
|
168
|
-
* Single
|
|
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
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
-
|
|
179
|
-
if (
|
|
180
|
-
|
|
212
|
+
private httpError(status: number, errorBody: ApiErrorBody): SparkVaultMobileError {
|
|
213
|
+
if (errorBody.meta) {
|
|
214
|
+
this.lastMeta = errorBody.meta;
|
|
181
215
|
}
|
|
182
216
|
|
|
183
|
-
|
|
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(
|
|
195
|
-
|
|
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
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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<
|
|
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
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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<
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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
|
|
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
|
-
|
|
287
|
+
const errorBody: ApiErrorBody = await response.json().catch(() => ({}));
|
|
288
|
+
return { status: 'transient', error: this.httpError(response.status, errorBody) };
|
|
253
289
|
}
|
|
254
290
|
|
|
255
|
-
|
|
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
|
-
|
|
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
|
|
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,
|
|
296
|
-
const url = this.makeUrl(
|
|
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.
|
|
369
|
+
throw this.transportError(err);
|
|
306
370
|
}
|
|
307
371
|
this.parseRateLimitHeaders(response.headers);
|
|
308
372
|
|
|
309
373
|
if (response.status === 401 && !options.skipAuth) {
|
|
310
|
-
|
|
311
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
353
|
-
|
|
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
|
-
|
|
413
|
-
return this.request<T>('
|
|
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
|
-
|
|
417
|
-
return this.request<T>('
|
|
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
|
-
|
|
421
|
-
return this.request<T>('
|
|
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
|
-
|
|
425
|
-
|
|
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 {
|
package/src/identity-dialog.tsx
CHANGED
|
@@ -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(
|
|
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={
|
|
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
|
|
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,
|