@sparkvault/sdk-mobile 0.3.0 → 0.3.2

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.
@@ -81,6 +81,11 @@ export function SparkVaultIdentityDialog({
81
81
  () => getMethodsForIdentity(config, identityType, passkeyProvider),
82
82
  [config, identityType, passkeyProvider]
83
83
  );
84
+ const methodsWithoutIdentity = useMemo(
85
+ () => getMethodsWithoutIdentity(config, passkeyProvider),
86
+ [config, passkeyProvider]
87
+ );
88
+ const visibleMethods = allowedTypes.length > 0 ? methodsForIdentity : methodsWithoutIdentity;
84
89
 
85
90
  useEffect(() => {
86
91
  onSuccessRef.current = onSuccess;
@@ -110,8 +115,16 @@ export function SparkVaultIdentityDialog({
110
115
  .then((nextConfig) => {
111
116
  if (cancelled || !isActiveSession(sessionRef, sessionId)) return;
112
117
  setConfig(nextConfig);
113
- setIdentityType(resolveInitialIdentityType(initialIdentity, initialIdentityType, normalizeAllowedTypes(nextConfig)));
114
- setStep('identity');
118
+ const nextAllowedTypes = normalizeAllowedTypes(nextConfig);
119
+ setIdentityType(resolveInitialIdentityType(initialIdentity, initialIdentityType, nextAllowedTypes));
120
+ if (nextAllowedTypes.length > 0) {
121
+ setStep('identity');
122
+ } else if (getMethodsWithoutIdentity(nextConfig, passkeyProvider).length > 0) {
123
+ setStep('methods');
124
+ } else {
125
+ setError('No supported sign-in methods are enabled for this app.');
126
+ setStep('error');
127
+ }
115
128
  })
116
129
  .catch((err: unknown) => {
117
130
  if (cancelled || !isActiveSession(sessionRef, sessionId)) return;
@@ -124,7 +137,7 @@ export function SparkVaultIdentityDialog({
124
137
  return () => {
125
138
  cancelled = true;
126
139
  };
127
- }, [client, initialIdentity, initialIdentityType, visible]);
140
+ }, [client, initialIdentity, initialIdentityType, passkeyProvider, visible]);
128
141
 
129
142
  useEffect(() => {
130
143
  if (!expiresAt || step !== 'totp') {
@@ -193,10 +206,7 @@ export function SparkVaultIdentityDialog({
193
206
  }
194
207
  }, [client, identity, identityType, reportError]);
195
208
 
196
- const runPasskey = useCallback(async (
197
- targetIdentity = identity,
198
- targetIdentityType = identityType
199
- ) => {
209
+ const runPasskey = useCallback(async () => {
200
210
  if (!passkeyProvider) {
201
211
  setError('Passkeys are not available in this app.');
202
212
  return;
@@ -215,7 +225,7 @@ export function SparkVaultIdentityDialog({
215
225
  }
216
226
  if (!isActiveSession(sessionRef, sessionId)) return;
217
227
 
218
- const challenge = await client.auth.getPasskeyAuthOptions(targetIdentity, targetIdentityType);
228
+ const challenge = await client.auth.getPasskeyAuthOptions();
219
229
  if (!isActiveSession(sessionRef, sessionId)) return;
220
230
  const credential = await passkeyProvider.authenticate(challenge.options);
221
231
  if (!isActiveSession(sessionRef, sessionId)) return;
@@ -233,7 +243,7 @@ export function SparkVaultIdentityDialog({
233
243
  setIsBusy(false);
234
244
  }
235
245
  }
236
- }, [client, finishWithToken, identity, identityType, passkeyProvider, reportError]);
246
+ }, [client, finishWithToken, passkeyProvider, reportError]);
237
247
 
238
248
  const handleIdentitySubmit = useCallback(async () => {
239
249
  if (isBusy) return;
@@ -259,18 +269,12 @@ export function SparkVaultIdentityDialog({
259
269
  const sessionId = sessionRef.current;
260
270
 
261
271
  try {
262
- if (availableMethods.includes('passkey')) {
263
- const { hasPasskey } = await client.auth.checkPasskeyStatus(normalizedIdentity, nextIdentityType);
264
- if (!isActiveSession(sessionRef, sessionId)) return;
265
- if (hasPasskey) {
266
- await runPasskey(normalizedIdentity, nextIdentityType);
267
- return;
272
+ if (availableMethods.length === 1) {
273
+ if (availableMethods[0] === 'passkey') {
274
+ await runPasskey();
275
+ } else {
276
+ await sendTotp(availableMethods[0], normalizedIdentity, nextIdentityType);
268
277
  }
269
- }
270
-
271
- const totpMethods = availableMethods.filter((method) => method !== 'passkey');
272
- if (totpMethods.length === 1) {
273
- await sendTotp(totpMethods[0], normalizedIdentity, nextIdentityType);
274
278
  return;
275
279
  }
276
280
 
@@ -283,7 +287,7 @@ export function SparkVaultIdentityDialog({
283
287
  setIsBusy(false);
284
288
  }
285
289
  }
286
- }, [allowedTypes, client, config, identity, isBusy, passkeyProvider, reportError, runPasskey, sendTotp]);
290
+ }, [allowedTypes, config, identity, isBusy, passkeyProvider, reportError, runPasskey, sendTotp]);
287
291
 
288
292
  const handleMethodSelect = useCallback(async (method: SupportedMethodId) => {
289
293
  if (method === 'passkey') {
@@ -341,8 +345,16 @@ export function SparkVaultIdentityDialog({
341
345
  .then((nextConfig) => {
342
346
  if (!isActiveSession(sessionRef, sessionId)) return;
343
347
  setConfig(nextConfig);
344
- setIdentityType(resolveInitialIdentityType(initialIdentity, initialIdentityType, normalizeAllowedTypes(nextConfig)));
345
- setStep('identity');
348
+ const nextAllowedTypes = normalizeAllowedTypes(nextConfig);
349
+ setIdentityType(resolveInitialIdentityType(initialIdentity, initialIdentityType, nextAllowedTypes));
350
+ if (nextAllowedTypes.length > 0) {
351
+ setStep('identity');
352
+ } else if (getMethodsWithoutIdentity(nextConfig, passkeyProvider).length > 0) {
353
+ setStep('methods');
354
+ } else {
355
+ setError('No supported sign-in methods are enabled for this app.');
356
+ setStep('error');
357
+ }
346
358
  })
347
359
  .catch((err: unknown) => {
348
360
  if (!isActiveSession(sessionRef, sessionId)) return;
@@ -354,7 +366,7 @@ export function SparkVaultIdentityDialog({
354
366
  setIsBusy(false);
355
367
  }
356
368
  });
357
- }, [client, initialIdentity, initialIdentityType, reportError]);
369
+ }, [client, initialIdentity, initialIdentityType, passkeyProvider, reportError]);
358
370
 
359
371
  const headerLogo = getHeaderLogo(branding, effectiveTheme);
360
372
  const companyName = branding?.companyName || 'SparkVault';
@@ -446,9 +458,9 @@ export function SparkVaultIdentityDialog({
446
458
  {step === 'methods' && (
447
459
  <View style={styles.body}>
448
460
  <Text style={[styles.title, { color: colors.text }]}>Choose a method</Text>
449
- <Text style={[styles.subtitle, { color: colors.muted }]}>{identity}</Text>
461
+ {identity ? <Text style={[styles.subtitle, { color: colors.muted }]}>{identity}</Text> : null}
450
462
  <View style={styles.methodList}>
451
- {methodsForIdentity.map((method) => (
463
+ {visibleMethods.map((method) => (
452
464
  <Pressable
453
465
  key={method}
454
466
  accessibilityRole="button"
@@ -471,7 +483,9 @@ export function SparkVaultIdentityDialog({
471
483
  ))}
472
484
  </View>
473
485
  {error ? <Text style={[styles.error, { color: colors.error }]}>{error}</Text> : null}
474
- <TextButton label="Use a different identity" colors={colors} onPress={() => setStep('identity')} />
486
+ {allowedTypes.length > 0 ? (
487
+ <TextButton label="Use a different identity" colors={colors} onPress={() => setStep('identity')} />
488
+ ) : null}
475
489
  </View>
476
490
  )}
477
491
 
@@ -493,7 +507,7 @@ export function SparkVaultIdentityDialog({
493
507
  loading={isBusy}
494
508
  onPress={runPasskey}
495
509
  />
496
- {fallbackTotpMethod(methodsForIdentity) ? (
510
+ {allowedTypes.length > 0 && fallbackTotpMethod(methodsForIdentity) ? (
497
511
  <TextButton
498
512
  label={fallbackLabel(fallbackTotpMethod(methodsForIdentity)!)}
499
513
  colors={colors}
@@ -631,8 +645,9 @@ function TextButton({
631
645
  }
632
646
 
633
647
  function normalizeAllowedTypes(config: IdentityConfig | null): IdentityType[] {
634
- const allowed = config?.allowedIdentityTypes?.filter((type): type is IdentityType => type === 'email' || type === 'phone');
635
- return allowed && allowed.length > 0 ? allowed : ['email'];
648
+ if (!config) return ['email'];
649
+ if (!config.allowedIdentityTypes) return ['email'];
650
+ return config.allowedIdentityTypes.filter((type): type is IdentityType => type === 'email' || type === 'phone');
636
651
  }
637
652
 
638
653
  function resolveInitialIdentityType(
@@ -653,12 +668,22 @@ function getMethodsForIdentity(
653
668
  const configuredMethods = config?.methods ?? ['totp_email'];
654
669
  return configuredMethods.filter((method): method is SupportedMethodId => {
655
670
  if (!SUPPORTED_METHODS.has(method)) return false;
656
- if (method === 'passkey') return identityType === 'email' && Boolean(passkeyProvider);
671
+ if (method === 'passkey') return Boolean(passkeyProvider);
657
672
  if (method === 'totp_email') return identityType === 'email';
658
673
  return identityType === 'phone';
659
674
  });
660
675
  }
661
676
 
677
+ function getMethodsWithoutIdentity(
678
+ config: IdentityConfig | null,
679
+ passkeyProvider?: MobilePasskeyProvider
680
+ ): SupportedMethodId[] {
681
+ const configuredMethods = config?.methods ?? [];
682
+ return configuredMethods.filter((method): method is SupportedMethodId => (
683
+ method === 'passkey' && Boolean(passkeyProvider)
684
+ ));
685
+ }
686
+
662
687
  function parseIdentity(
663
688
  value: string,
664
689
  allowedTypes: IdentityType[]
package/src/ingots.ts CHANGED
@@ -66,6 +66,34 @@ interface IngotUploadInitResponse {
66
66
  size_bytes?: number;
67
67
  }
68
68
 
69
+ interface IngotAuditLogEntry {
70
+ id?: string;
71
+ log_id?: string;
72
+ event_id?: string;
73
+ event_type?: string;
74
+ action?: string;
75
+ actor?: {
76
+ email?: string;
77
+ id?: string;
78
+ user_id?: string;
79
+ api_key_id?: string;
80
+ };
81
+ actor_email?: string;
82
+ actor_id?: string;
83
+ identity?: string;
84
+ ip_address?: string;
85
+ user_agent?: string;
86
+ timestamp?: number;
87
+ created_at?: number;
88
+ }
89
+
90
+ interface IngotAuditLogsResponse {
91
+ entries?: IngotAuditLogEntry[];
92
+ logs?: IngotAuditLogEntry[];
93
+ cursor?: string;
94
+ next_cursor?: string;
95
+ }
96
+
69
97
  export class MobileIngotsClient {
70
98
  private static readonly INGOT_STATUS_POLL_MAX = 10;
71
99
  private static readonly INGOT_STATUS_POLL_MS = 1000;
@@ -422,29 +450,36 @@ export class MobileIngotsClient {
422
450
  if (options?.cursor) params.append('cursor', options.cursor);
423
451
 
424
452
  const query = params.toString();
425
- const response = await this.http.get<{ logs: IngotAccessLog[]; next_cursor?: string }>(
453
+ const response = await this.http.get<IngotAuditLogsResponse>(
426
454
  query
427
- ? `/vaults/${vaultId}/ingots/${ingotId}/access-logs?${query}`
428
- : `/vaults/${vaultId}/ingots/${ingotId}/access-logs`,
455
+ ? `/vaults/${vaultId}/ingots/${ingotId}/audit-logs?${query}`
456
+ : `/vaults/${vaultId}/ingots/${ingotId}/audit-logs`,
429
457
  { vat }
430
458
  );
431
- return response.data;
459
+ const entries = response.data.entries ?? response.data.logs ?? [];
460
+ return {
461
+ logs: entries.map(entry => this.toAccessLog(entry)),
462
+ next_cursor: response.data.cursor ?? response.data.next_cursor,
463
+ };
432
464
  }
433
465
 
434
466
  async getAccessStats(vaultId: string, ingotId: string, vat: string): Promise<IngotAccessStats> {
435
467
  validateVaultId(vaultId);
436
468
  validateIngotId(ingotId);
437
- const response = await this.http.get<IngotAccessStats>(
438
- `/vaults/${vaultId}/ingots/${ingotId}/access-stats`,
439
- { vat }
440
- );
441
- return response.data;
469
+ const ingot = await this.get(vaultId, ingotId, vat);
470
+ return {
471
+ total_downloads: ingot.access_count ?? 0,
472
+ last_accessed_at: ingot.last_accessed_at ?? ingot.accessed_at,
473
+ };
442
474
  }
443
475
 
444
476
  async purgeAccessLogs(vaultId: string, ingotId: string, vat: string): Promise<void> {
445
477
  validateVaultId(vaultId);
446
478
  validateIngotId(ingotId);
447
- await this.http.delete(`/vaults/${vaultId}/ingots/${ingotId}/access-logs`, { vat });
479
+ void vat;
480
+ throw new SparkVaultValidationError(
481
+ 'Ingot audit logs are read-only. Configure vault access-log retention to expire old entries.'
482
+ );
448
483
  }
449
484
 
450
485
  private async createIngotUpload(options: {
@@ -498,6 +533,28 @@ export class MobileIngotsClient {
498
533
  throw new SparkVaultMobileError(`Upload failed: ingot status is ${ingot?.status ?? 'unknown'}`);
499
534
  }
500
535
 
536
+ private toAccessLog(entry: IngotAuditLogEntry): IngotAccessLog {
537
+ const eventType = entry.event_type ?? entry.action ?? '';
538
+ const action = eventType.includes('download') ? 'download' : 'view';
539
+ const actor = entry.actor;
540
+ return {
541
+ log_id: entry.log_id ?? entry.event_id ?? entry.id ?? String(entry.timestamp ?? entry.created_at ?? ''),
542
+ action,
543
+ identity:
544
+ actor?.email ??
545
+ entry.actor_email ??
546
+ entry.identity ??
547
+ actor?.id ??
548
+ actor?.user_id ??
549
+ actor?.api_key_id ??
550
+ entry.actor_id ??
551
+ 'Unknown',
552
+ ip_address: entry.ip_address ?? '',
553
+ user_agent: entry.user_agent ?? '',
554
+ timestamp: entry.timestamp ?? entry.created_at ?? 0,
555
+ };
556
+ }
557
+
501
558
  private async verifyDownloadedFile(
502
559
  fileDownloader: MobileFileDownloader,
503
560
  preferredUri: string,
package/src/types.ts CHANGED
@@ -55,6 +55,7 @@ export interface SparkVaultAccount {
55
55
  }
56
56
 
57
57
  export type IdentityType = 'email' | 'phone';
58
+ export type VerifiedIdentityType = IdentityType | 'social';
58
59
 
59
60
  export type IdentityMethodId =
60
61
  | 'totp_email'
@@ -112,7 +113,7 @@ export interface IdentityTokenClaims {
112
113
  nbf?: number;
113
114
  jti?: string;
114
115
  identity: string;
115
- identity_type: IdentityType;
116
+ identity_type: VerifiedIdentityType;
116
117
  verified_at: number;
117
118
  method: string;
118
119
  }
@@ -120,7 +121,7 @@ export interface IdentityTokenClaims {
120
121
  export interface IdentityVerifyResult {
121
122
  token: string;
122
123
  identity: string;
123
- identityType: IdentityType;
124
+ identityType: VerifiedIdentityType;
124
125
  method?: string;
125
126
  redirect?: string;
126
127
  jwksUri: string;
@@ -195,10 +196,11 @@ export interface PasskeyAuthOptions {
195
196
  challenge: string;
196
197
  timeout: number;
197
198
  rpId: string;
198
- allowCredentials?: {
199
+ allowCredentials?: Array<{
199
200
  id: string;
200
201
  type: 'public-key';
201
- }[];
202
+ transports?: string[];
203
+ }>;
202
204
  userVerification: 'required' | 'preferred' | 'discouraged';
203
205
  }
204
206
 
@@ -226,6 +228,7 @@ export interface PasskeyRegisterOptions {
226
228
  authenticatorSelection: {
227
229
  userVerification: 'required' | 'preferred' | 'discouraged';
228
230
  residentKey: 'required' | 'preferred' | 'discouraged';
231
+ requireResidentKey?: boolean;
229
232
  };
230
233
  }
231
234
 
@@ -329,6 +332,7 @@ export interface Ingot {
329
332
  folder_id?: string | null;
330
333
  created_at: number;
331
334
  accessed_at?: number;
335
+ last_accessed_at?: number;
332
336
  access_count?: number;
333
337
  expires_at?: number;
334
338
  }
@@ -372,7 +376,7 @@ export interface IngotAccessLog {
372
376
 
373
377
  export interface IngotAccessStats {
374
378
  total_downloads: number;
375
- unique_visitors: number;
379
+ unique_visitors?: number;
376
380
  last_accessed_at?: number;
377
381
  }
378
382
 
package/src/vaults.ts CHANGED
@@ -19,6 +19,49 @@ export interface SharingConfig {
19
19
  enabled_at: number | null;
20
20
  }
21
21
 
22
+ export interface UploadConfig {
23
+ vault_id: string;
24
+ upload_portal_enabled: boolean;
25
+ upload_portal_enabled_at: number | null;
26
+ upload_widget_enabled: boolean;
27
+ upload_widget_enabled_at: number | null;
28
+ max_size_bytes: number | null;
29
+ notification_email: string | null;
30
+ portal_url?: string;
31
+ }
32
+
33
+ export interface UploadPortalEnabled {
34
+ vault_id: string;
35
+ upload_portal_enabled: true;
36
+ enabled_at: number;
37
+ portal_url: string;
38
+ }
39
+
40
+ export interface UploadWidgetEnabled {
41
+ vault_id: string;
42
+ upload_widget_enabled: true;
43
+ enabled_at: number;
44
+ }
45
+
46
+ export interface UploadPortalDisabled {
47
+ vault_id: string;
48
+ upload_portal_enabled: false;
49
+ disabled_at: number;
50
+ }
51
+
52
+ export interface UploadWidgetDisabled {
53
+ vault_id: string;
54
+ upload_widget_enabled: false;
55
+ disabled_at: number;
56
+ }
57
+
58
+ export interface UploadConfigUpdate {
59
+ vault_id: string;
60
+ max_size_bytes: number;
61
+ notification_email: string | null;
62
+ updated_at: number;
63
+ }
64
+
22
65
  export class MobileVaultsClient {
23
66
  private readonly http: MobileHttpClient;
24
67
 
@@ -180,92 +223,63 @@ export class MobileVaultsClient {
180
223
  return response.data;
181
224
  }
182
225
 
183
- async getUploadConfig(vaultId: string): Promise<{
184
- vault_id: string;
185
- public_upload_enabled: boolean;
186
- upload_url: string | null;
187
- max_size_bytes: number | null;
188
- notification_email: string | null;
189
- enabled_at: number | null;
190
- }> {
226
+ async getUploadConfig(vaultId: string): Promise<UploadConfig> {
191
227
  validateVaultId(vaultId);
192
- const response = await this.http.get<{
193
- vault_id: string;
194
- public_upload_enabled: boolean;
195
- upload_url: string | null;
196
- max_size_bytes: number | null;
197
- notification_email: string | null;
198
- enabled_at: number | null;
199
- }>(`/vaults/${vaultId}/upload`);
228
+ const response = await this.http.get<UploadConfig>(`/vaults/${vaultId}/upload`);
200
229
  return response.data;
201
230
  }
202
231
 
203
- async enableUpload(
204
- vaultId: string,
205
- vmk: string | null,
206
- options?: {
207
- max_size_bytes?: number;
208
- notification_email?: string;
209
- }
210
- ): Promise<{
211
- vault_id: string;
212
- public_upload_enabled: boolean;
213
- upload_url: string;
214
- max_size_bytes: number;
215
- notification_email: string | null;
216
- enabled_at: number;
217
- }> {
232
+ async enableUploadPortal(vaultId: string, vmk: string): Promise<UploadPortalEnabled> {
218
233
  validateVaultId(vaultId);
219
- const response = await this.http.post<{
220
- vault_id: string;
221
- public_upload_enabled: boolean;
222
- upload_url: string;
223
- max_size_bytes: number;
224
- notification_email: string | null;
225
- enabled_at: number;
226
- }>(`/vaults/${vaultId}/upload/enable`, {
234
+ const response = await this.http.post<UploadPortalEnabled>(`/vaults/${vaultId}/upload/portal/enable`, {
227
235
  vmk,
228
- max_size_bytes: options?.max_size_bytes ?? null,
229
- notification_email: options?.notification_email ?? null,
230
236
  });
231
237
  return response.data;
232
238
  }
233
239
 
234
- async disableUpload(vaultId: string): Promise<{
235
- vault_id: string;
236
- public_upload_enabled: false;
237
- disabled_at: number;
238
- }> {
240
+ async disableUploadPortal(vaultId: string): Promise<UploadPortalDisabled> {
239
241
  validateVaultId(vaultId);
240
- const response = await this.http.post<{
241
- vault_id: string;
242
- public_upload_enabled: false;
243
- disabled_at: number;
244
- }>(`/vaults/${vaultId}/upload/disable`);
242
+ const response = await this.http.post<UploadPortalDisabled>(`/vaults/${vaultId}/upload/portal/disable`);
243
+ return response.data;
244
+ }
245
+
246
+ async enableUploadWidget(vaultId: string, vmk: string): Promise<UploadWidgetEnabled> {
247
+ validateVaultId(vaultId);
248
+ const response = await this.http.post<UploadWidgetEnabled>(`/vaults/${vaultId}/upload/widget/enable`, {
249
+ vmk,
250
+ });
245
251
  return response.data;
246
252
  }
247
253
 
254
+ async disableUploadWidget(vaultId: string): Promise<UploadWidgetDisabled> {
255
+ validateVaultId(vaultId);
256
+ const response = await this.http.post<UploadWidgetDisabled>(`/vaults/${vaultId}/upload/widget/disable`);
257
+ return response.data;
258
+ }
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
+
248
274
  async updateUploadConfig(
249
275
  vaultId: string,
250
276
  config: {
251
277
  max_size_bytes?: number;
252
278
  notification_email?: string | null;
253
279
  }
254
- ): Promise<{
255
- vault_id: string;
256
- public_upload_enabled: boolean;
257
- max_size_bytes: number;
258
- notification_email: string | null;
259
- updated_at: number;
260
- }> {
280
+ ): Promise<UploadConfigUpdate> {
261
281
  validateVaultId(vaultId);
262
- const response = await this.http.put<{
263
- vault_id: string;
264
- public_upload_enabled: boolean;
265
- max_size_bytes: number;
266
- notification_email: string | null;
267
- updated_at: number;
268
- }>(`/vaults/${vaultId}/upload`, config);
282
+ const response = await this.http.put<UploadConfigUpdate>(`/vaults/${vaultId}/upload`, config);
269
283
  return response.data;
270
284
  }
271
285
  }