@sparkvault/sdk-mobile 0.3.3 → 1.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/src/auth.ts CHANGED
@@ -25,6 +25,7 @@ import type {
25
25
  SendTotpRequest,
26
26
  SparkVaultAccount,
27
27
  SparkVaultUser,
28
+ SubmitSecondFactorRequest,
28
29
  VerifyTotpRequest,
29
30
  } from './types.js';
30
31
  import nacl from 'tweetnacl';
@@ -39,6 +40,12 @@ interface IdentityVerificationResponse {
39
40
  kindling?: string;
40
41
  expires_at?: number;
41
42
  retry_after?: number;
43
+ /** Primary factor passed, second factor still owed. */
44
+ second_factor_required?: boolean;
45
+ /** Pending-login handle for the second-factor submit. */
46
+ ticket?: string;
47
+ /** Offered second-factor methods. */
48
+ methods?: string[];
42
49
  }
43
50
 
44
51
  interface IdentityPasskeyCompletionResponse {
@@ -171,9 +178,6 @@ export class MobileAuthClient {
171
178
  if (claims.nbf !== undefined && claims.nbf - clockSkewSeconds > now) {
172
179
  throw new SparkVaultValidationError('Identity token is not valid yet');
173
180
  }
174
- if (typeof claims.nbf === 'number' && claims.nbf > now + clockSkewSeconds) {
175
- throw new SparkVaultValidationError('Identity token is not yet valid');
176
- }
177
181
 
178
182
  const expectedIssuer = `${this.config.identityBaseUrl}/${this.config.identityAccountId}`;
179
183
  if (claims.iss !== expectedIssuer) {
@@ -249,23 +253,15 @@ export class MobileAuthClient {
249
253
 
250
254
  async verifyTotp(request: VerifyTotpRequest): Promise<IdentityVerifyResult> {
251
255
  const verifyResult = await this.verifyTotpWithRetryErrors(request);
252
- if (!verifyResult.verified || !verifyResult.token) {
253
- if (verifyResult.kindling) {
254
- const error = new SparkVaultMobileError('Invalid code. Please try again.', {
255
- code: 400,
256
- statusCode: 400,
257
- });
258
- (error as SparkVaultMobileError & { data?: unknown }).data = {
259
- kindling: verifyResult.kindling,
260
- expires_at: verifyResult.expires_at,
261
- };
262
- throw error;
263
- }
264
256
 
265
- throw new SparkVaultMobileError('Verification failed. Please request a new code.', {
266
- code: 400,
267
- statusCode: 400,
268
- });
257
+ // PIN accepted, but the account requires a second factor. Surface the
258
+ // ticket so the caller can collect a code and call submitSecondFactor().
259
+ if (verifyResult.second_factor_required && verifyResult.ticket) {
260
+ throw this.secondFactorError(verifyResult.ticket, verifyResult.methods);
261
+ }
262
+
263
+ if (!verifyResult.verified || !verifyResult.token) {
264
+ this.throwVerificationFailure(verifyResult);
269
265
  }
270
266
 
271
267
  return this.toIdentityVerifyResult(verifyResult.token, {
@@ -279,26 +275,94 @@ export class MobileAuthClient {
279
275
  async verifyPin(request: PinVerifyRequest): Promise<PinVerifyResponse> {
280
276
  const verifyResult = await this.verifyTotpWithRetryErrors(request);
281
277
 
278
+ // PIN accepted, but 2FA is required. Return a pending result so the caller
279
+ // can collect a second factor and call completeSecondFactorPin().
280
+ if (verifyResult.second_factor_required && verifyResult.ticket) {
281
+ return {
282
+ verified: false,
283
+ second_factor_required: true,
284
+ ticket: verifyResult.ticket,
285
+ methods: verifyResult.methods ?? ['authenticator'],
286
+ };
287
+ }
288
+
282
289
  if (!verifyResult.verified || !verifyResult.token) {
283
- if (verifyResult.kindling) {
284
- const error = new SparkVaultMobileError('Invalid code. Please try again.', {
285
- code: 400,
286
- statusCode: 400,
287
- });
288
- (error as SparkVaultMobileError & { data?: unknown }).data = {
289
- kindling: verifyResult.kindling,
290
- expires_at: verifyResult.expires_at,
291
- };
292
- throw error;
293
- }
290
+ this.throwVerificationFailure(verifyResult);
291
+ }
292
+
293
+ return this.exchangeIdentityToken(verifyResult.token);
294
+ }
295
+
296
+ /**
297
+ * Surface a failed primary-factor verification. When the backend returns a
298
+ * retry kindling, it is grafted onto the error's `data` so the caller can
299
+ * offer another attempt against the same code session.
300
+ */
301
+ private throwVerificationFailure(verifyResult: IdentityVerificationResponse): never {
302
+ if (verifyResult.kindling) {
303
+ const error = new SparkVaultMobileError('Invalid code. Please try again.', {
304
+ code: 400,
305
+ statusCode: 400,
306
+ });
307
+ (error as SparkVaultMobileError & { data?: unknown }).data = {
308
+ kindling: verifyResult.kindling,
309
+ expires_at: verifyResult.expires_at,
310
+ };
311
+ throw error;
312
+ }
294
313
 
295
- throw new SparkVaultMobileError('Verification failed. Please request a new code.', {
314
+ throw new SparkVaultMobileError('Verification failed. Please request a new code.', {
315
+ code: 400,
316
+ statusCode: 400,
317
+ });
318
+ }
319
+
320
+ /**
321
+ * Complete a login held at the second-factor gate (from verifyTotp). Submits
322
+ * a 6-digit authenticator code or a recovery code and returns the verified
323
+ * identity result. A wrong code throws.
324
+ */
325
+ async submitSecondFactor(request: SubmitSecondFactorRequest): Promise<IdentityVerifyResult> {
326
+ const result = await this.postIdentity<IdentityVerificationResponse>('/second-factor/verify', {
327
+ ticket: request.ticket,
328
+ code: request.code,
329
+ });
330
+ if (!result.verified || !result.token) {
331
+ throw new SparkVaultMobileError('That code is not valid. Please try again.', {
296
332
  code: 400,
297
333
  statusCode: 400,
298
334
  });
299
335
  }
336
+ return this.toIdentityVerifyResult(result.token, {
337
+ identity: result.identity,
338
+ identityType: result.identity_type,
339
+ method: result.method,
340
+ redirect: result.redirect,
341
+ });
342
+ }
300
343
 
301
- return this.exchangeIdentityToken(verifyResult.token);
344
+ /**
345
+ * Complete a 2FA-pending app login (from verifyPin's pending response):
346
+ * submit the second factor, then exchange the identity token for an app
347
+ * session.
348
+ */
349
+ async completeSecondFactorPin(request: SubmitSecondFactorRequest): Promise<PinVerifyResponse> {
350
+ const result = await this.submitSecondFactor(request);
351
+ return this.exchangeIdentityToken(result.token);
352
+ }
353
+
354
+ /** Build the typed error verifyTotp throws when a second factor is required. */
355
+ private secondFactorError(ticket: string, methods?: string[]): SparkVaultMobileError {
356
+ const error = new SparkVaultMobileError('Two-step verification required.', {
357
+ code: 401,
358
+ statusCode: 401,
359
+ });
360
+ (error as SparkVaultMobileError & { data?: unknown }).data = {
361
+ second_factor_required: true,
362
+ ticket,
363
+ methods: methods ?? ['authenticator'],
364
+ };
365
+ return error;
302
366
  }
303
367
 
304
368
  async completeSignup(
package/src/config.ts CHANGED
@@ -1,4 +1,8 @@
1
1
  import { SparkVaultValidationError } from './errors.js';
2
+ import {
3
+ SPARKVAULT_PLATFORM_SV_HOSTS,
4
+ SPARKVAULT_ROOT_DOMAIN,
5
+ } from './platform-domains.generated.js';
2
6
  import type {
3
7
  FetchLike,
4
8
  MobileFileDownloader,
@@ -10,11 +14,15 @@ import type {
10
14
  const DEFAULT_API_BASE_URL = 'https://api.sparkvault.com/v1';
11
15
  const DEFAULT_IDENTITY_BASE_URL = 'https://api.sparkvault.com/v1/products/identity';
12
16
 
13
- // Hosts that are allowed to serve backend-issued ingot download URLs.
14
- // Keep in sync with the sdk-js default in packages/sdk-js/src/config.ts.
17
+ function escapeRegExp(value: string): string {
18
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
19
+ }
20
+
21
+ // Hosts that are allowed to serve backend-issued ingot download URLs, derived
22
+ // from the canonical platform domain registry baked in at build time.
15
23
  const DEFAULT_ALLOWED_DOWNLOAD_HOST_PATTERNS: RegExp[] = [
16
- /(^|\.)sparkvault\.com$/i,
17
- /(^|\.)(x|files|file|send|spark|by|at|db|auth)\.sv$/i,
24
+ new RegExp(`(^|\\.)${escapeRegExp(SPARKVAULT_ROOT_DOMAIN)}$`, 'i'),
25
+ new RegExp(`(^|\\.)(${SPARKVAULT_PLATFORM_SV_HOSTS.map(escapeRegExp).join('|')})$`, 'i'),
18
26
  /\.amazonaws\.com$/i,
19
27
  /\.cloudfront\.net$/i,
20
28
  ];
package/src/encoding.ts CHANGED
@@ -123,7 +123,17 @@ export function base64ToBytes(value: string): Uint8Array {
123
123
  }
124
124
 
125
125
  const padded = sanitized.padEnd(Math.ceil(sanitized.length / 4) * 4, '=');
126
- const bytes: number[] = [];
126
+
127
+ // Preallocate the exact output size: 3 bytes per quartet, minus one byte per
128
+ // '=' (a '=' in quartet positions 1-2 throws below, so counting every '=' is
129
+ // safe). Multi-megabyte upload chunks decode through here, so per-byte array
130
+ // growth is not acceptable.
131
+ let paddingCount = 0;
132
+ for (let i = 0; i < padded.length; i++) {
133
+ if (padded[i] === '=') paddingCount++;
134
+ }
135
+ const bytes = new Uint8Array((padded.length / 4) * 3 - paddingCount);
136
+ let outIndex = 0;
127
137
 
128
138
  for (let i = 0; i < padded.length; i += 4) {
129
139
  const c1 = padded[i];
@@ -141,10 +151,10 @@ export function base64ToBytes(value: string): Uint8Array {
141
151
  }
142
152
 
143
153
  const triplet = (v1 << 18) | (v2 << 12) | (v3 << 6) | v4;
144
- bytes.push((triplet >> 16) & 0xff);
145
- if (c3 !== '=') bytes.push((triplet >> 8) & 0xff);
146
- if (c4 !== '=') bytes.push(triplet & 0xff);
154
+ bytes[outIndex++] = (triplet >> 16) & 0xff;
155
+ if (c3 !== '=') bytes[outIndex++] = (triplet >> 8) & 0xff;
156
+ if (c4 !== '=') bytes[outIndex++] = triplet & 0xff;
147
157
  }
148
158
 
149
- return new Uint8Array(bytes);
159
+ return bytes;
150
160
  }
package/src/http.ts CHANGED
@@ -231,9 +231,20 @@ export class MobileHttpClient {
231
231
  return false;
232
232
  }
233
233
 
234
- await this.config.tokenStorage.setAccessToken(data.access_token);
234
+ // Refresh tokens rotate and are one-time-use: persist the pair through a
235
+ // single adapter call when the adapter supports it, so the write can be
236
+ // atomic. Two separate writes risk a process kill stranding a new access
237
+ // token alongside the already-consumed refresh token (forced logout on
238
+ // the next refresh).
235
239
  if (typeof data.refresh_token === 'string' && data.refresh_token) {
236
- await this.config.tokenStorage.setRefreshToken(data.refresh_token);
240
+ if (this.config.tokenStorage.setTokens) {
241
+ await this.config.tokenStorage.setTokens(data.access_token, data.refresh_token);
242
+ } else {
243
+ await this.config.tokenStorage.setAccessToken(data.access_token);
244
+ await this.config.tokenStorage.setRefreshToken(data.refresh_token);
245
+ }
246
+ } else {
247
+ await this.config.tokenStorage.setAccessToken(data.access_token);
237
248
  }
238
249
  this.emitAuthEvent('token_refreshed');
239
250
  return true;
@@ -402,7 +402,7 @@ export function SparkVaultIdentityDialog({
402
402
  hitSlop={12}
403
403
  style={styles.closeButton}
404
404
  >
405
- <Text style={[styles.closeText, { color: colors.muted }]}>x</Text>
405
+ <Text style={[styles.closeText, { color: colors.muted }]}>✕</Text>
406
406
  </Pressable>
407
407
  </View>
408
408
 
@@ -496,7 +496,7 @@ export function SparkVaultIdentityDialog({
496
496
  Confirm this sign-in with your device passkey.
497
497
  </Text>
498
498
  <View style={[styles.passkeyIcon, { backgroundColor: colors.primarySoft }]}>
499
- <Text style={[styles.passkeyIconText, { color: colors.primary }]}>key</Text>
499
+ <Text style={styles.passkeyIconText}>🔑</Text>
500
500
  </View>
501
501
  {isBusy ? <ActivityIndicator color={colors.primary} /> : null}
502
502
  {error ? <Text style={[styles.error, { color: colors.error }]}>{error}</Text> : null}
@@ -974,7 +974,6 @@ const styles = StyleSheet.create({
974
974
  justifyContent: 'center',
975
975
  },
976
976
  passkeyIconText: {
977
- fontSize: 18,
978
- fontWeight: '800',
977
+ fontSize: 34,
979
978
  },
980
979
  });
package/src/ingots.ts CHANGED
@@ -89,8 +89,6 @@ interface IngotAuditLogEntry {
89
89
 
90
90
  interface IngotAuditLogsResponse {
91
91
  entries?: IngotAuditLogEntry[];
92
- logs?: IngotAuditLogEntry[];
93
- cursor?: string;
94
92
  next_cursor?: string;
95
93
  }
96
94
 
@@ -456,10 +454,10 @@ export class MobileIngotsClient {
456
454
  : `/vaults/${vaultId}/ingots/${ingotId}/audit-logs`,
457
455
  { vat }
458
456
  );
459
- const entries = response.data.entries ?? response.data.logs ?? [];
457
+ const entries = response.data.entries ?? [];
460
458
  return {
461
459
  logs: entries.map(entry => this.toAccessLog(entry)),
462
- next_cursor: response.data.cursor ?? response.data.next_cursor,
460
+ next_cursor: response.data.next_cursor,
463
461
  };
464
462
  }
465
463
 
@@ -0,0 +1,17 @@
1
+ // GENERATED FILE — do not edit by hand.
2
+ // Source of truth: packages/app-sdk/src/platform-domains.js
3
+ // Regenerate with: pnpm --filter @sparkvault/sdk-mobile build
4
+
5
+ export const SPARKVAULT_ROOT_DOMAIN = "sparkvault.com";
6
+
7
+ export const SPARKVAULT_PLATFORM_SV_HOSTS: readonly string[] = Object.freeze([
8
+ "auth.sv",
9
+ "x.sv",
10
+ "files.sv",
11
+ "file.sv",
12
+ "send.sv",
13
+ "spark.sv",
14
+ "by.sv",
15
+ "at.sv",
16
+ "db.sv",
17
+ ]);
package/src/tus.ts CHANGED
@@ -73,6 +73,12 @@ function ensureNotAborted(signal?: AbortSignal): void {
73
73
  }
74
74
 
75
75
  function toArrayBuffer(bytes: Uint8Array): ArrayBuffer {
76
+ // base64ToBytes returns exactly-sized buffers, so the chunk hot path is a
77
+ // zero-copy unwrap; copy only when the view spans part of a larger buffer.
78
+ // Aliasing is safe: chunks are sent then discarded, never mutated.
79
+ if (bytes.byteOffset === 0 && bytes.byteLength === bytes.buffer.byteLength) {
80
+ return bytes.buffer as ArrayBuffer;
81
+ }
76
82
  const copy = new Uint8Array(bytes.byteLength);
77
83
  copy.set(bytes);
78
84
  return copy.buffer;
@@ -223,10 +229,11 @@ export class MobileTusUploader {
223
229
  const debug = options.debug;
224
230
  const cleanUri = options.fileUri.split('#')[0];
225
231
  let bytesUploaded = 0;
232
+ let uploadUrl: string | null = null;
226
233
 
227
234
  try {
228
235
  debug?.log(`Starting TUS upload for ${options.filename}`);
229
- const uploadUrl = await createTusUpload(
236
+ uploadUrl = await createTusUpload(
230
237
  this.config,
231
238
  parsed,
232
239
  options.fileSize,
@@ -267,6 +274,18 @@ export class MobileTusUploader {
267
274
  } catch (err) {
268
275
  const error = err instanceof Error ? err : new Error(String(err));
269
276
  debug?.error(`Upload failed: ${getErrorMessage(error)}`);
277
+
278
+ // A cancelled session must be terminated server-side: the tus DELETE is
279
+ // what lets Forge remove already-written chunk objects (otherwise they
280
+ // are orphaned in S3 — nothing else cleans them up) and settle
281
+ // partial-transfer billing, matching the web client's abort semantics.
282
+ const cancelled =
283
+ options.abortSignal?.aborted === true ||
284
+ (err instanceof TusUploadError && err.phase === 'cancelled');
285
+ if (cancelled && uploadUrl) {
286
+ this.terminateUpload(uploadUrl, parsed.istk, debug);
287
+ }
288
+
270
289
  if (err instanceof TusUploadError) throw err;
271
290
  throw TusUploadError.fromError(error, {
272
291
  filename: options.filename,
@@ -276,6 +295,28 @@ export class MobileTusUploader {
276
295
  }
277
296
  }
278
297
 
298
+ /**
299
+ * Best-effort tus termination after a cancelled upload. Fire-and-forget so
300
+ * cancel UX stays instant and the cancellation error still propagates — a
301
+ * failed DELETE only means the orphaned session waits for server-side
302
+ * expiry. Deliberately NOT bound to the (already aborted) upload signal,
303
+ * which would kill the DELETE before it left the device.
304
+ */
305
+ private terminateUpload(uploadUrl: string, istk: string, debug?: DebugLogger): void {
306
+ void this.config
307
+ .fetch(uploadUrl, {
308
+ method: 'DELETE',
309
+ headers: {
310
+ 'Tus-Resumable': TUS_VERSION,
311
+ 'X-ISTK': istk,
312
+ },
313
+ timeoutMs: this.config.tusPostTimeoutMs,
314
+ })
315
+ .catch(err => {
316
+ debug?.log(`TUS termination after cancel failed: ${getErrorMessage(err)}`);
317
+ });
318
+ }
319
+
279
320
  private async readChunk(
280
321
  fileReader: MobileFileReader,
281
322
  fileUri: string,
package/src/types.ts CHANGED
@@ -174,7 +174,31 @@ export interface PinVerifySignupPendingResponse {
174
174
  expires_at: number;
175
175
  }
176
176
 
177
- export type PinVerifyResponse = PinVerifyLoginResponse | PinVerifySignupPendingResponse;
177
+ /**
178
+ * Returned by verifyPin when the PIN was correct but the account has 2FA
179
+ * enabled: the caller must collect a second factor and call
180
+ * completeSecondFactorPin({ ticket, code }) to finish the login.
181
+ */
182
+ export interface PinVerifySecondFactorPendingResponse {
183
+ verified: false;
184
+ second_factor_required: true;
185
+ /** Opaque pending-login handle to submit the second factor against. */
186
+ ticket: string;
187
+ /** Methods the user may use, e.g. ['authenticator','recovery_code']. */
188
+ methods: string[];
189
+ }
190
+
191
+ export type PinVerifyResponse =
192
+ | PinVerifyLoginResponse
193
+ | PinVerifySignupPendingResponse
194
+ | PinVerifySecondFactorPendingResponse;
195
+
196
+ export interface SubmitSecondFactorRequest {
197
+ /** Pending-login ticket from a second_factor_required response. */
198
+ ticket: string;
199
+ /** 6-digit authenticator code or a recovery code. */
200
+ code: string;
201
+ }
178
202
 
179
203
  export interface CompleteSignupRequest {
180
204
  organization_name: string;
package/src/validation.ts CHANGED
@@ -25,13 +25,20 @@ export function validateFolderId(folderId: string): void {
25
25
  }
26
26
  }
27
27
 
28
+ /** Windows reserved filenames that cannot be used. */
29
+ const WINDOWS_RESERVED_NAMES = new Set([
30
+ 'CON', 'PRN', 'AUX', 'NUL',
31
+ 'COM1', 'COM2', 'COM3', 'COM4', 'COM5', 'COM6', 'COM7', 'COM8', 'COM9',
32
+ 'LPT1', 'LPT2', 'LPT3', 'LPT4', 'LPT5', 'LPT6', 'LPT7', 'LPT8', 'LPT9',
33
+ ]);
34
+
35
+ /**
36
+ * Sanitize a filename to prevent path traversal and other security issues:
37
+ * removes null bytes and control characters, strips path traversal sequences,
38
+ * replaces directory separators, strips leading dots (hidden files), and
39
+ * prefixes Windows reserved names. Falls back to 'file' when nothing remains.
40
+ */
28
41
  export function sanitizeFilename(filename: string): string {
29
- const reservedNames = new Set([
30
- 'CON', 'PRN', 'AUX', 'NUL',
31
- 'COM1', 'COM2', 'COM3', 'COM4', 'COM5', 'COM6', 'COM7', 'COM8', 'COM9',
32
- 'LPT1', 'LPT2', 'LPT3', 'LPT4', 'LPT5', 'LPT6', 'LPT7', 'LPT8', 'LPT9',
33
- ]);
34
-
35
42
  let sanitized = filename
36
43
  .replace(/\0/g, '')
37
44
  .replace(/[\x00-\x1f\x7f]/g, '')
@@ -41,7 +48,7 @@ export function sanitizeFilename(filename: string): string {
41
48
  .trim();
42
49
 
43
50
  const baseName = sanitized.split('.')[0].toUpperCase();
44
- if (reservedNames.has(baseName)) {
51
+ if (WINDOWS_RESERVED_NAMES.has(baseName)) {
45
52
  sanitized = `_${sanitized}`;
46
53
  }
47
54
 
package/src/vaults.ts CHANGED
@@ -257,20 +257,6 @@ export class MobileVaultsClient {
257
257
  return response.data;
258
258
  }
259
259
 
260
- /**
261
- * @deprecated Use enableUploadPortal() or enableUploadWidget().
262
- */
263
- async enableUpload(vaultId: string, vmk: string): Promise<UploadPortalEnabled> {
264
- return this.enableUploadPortal(vaultId, vmk);
265
- }
266
-
267
- /**
268
- * @deprecated Use disableUploadPortal() or disableUploadWidget().
269
- */
270
- async disableUpload(vaultId: string): Promise<UploadPortalDisabled> {
271
- return this.disableUploadPortal(vaultId);
272
- }
273
-
274
260
  async updateUploadConfig(
275
261
  vaultId: string,
276
262
  config: {