@sparkvault/sdk 1.11.3 → 1.21.5

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 CHANGED
@@ -681,6 +681,21 @@ const result: VerifyResult = await sparkvault.identity.pop(options);
681
681
  // result.identityType: 'email' | 'phone'
682
682
  ```
683
683
 
684
+ ## Content Security Policy (CSP)
685
+
686
+ If your site uses a Content Security Policy, you must allow the SparkVault domains:
687
+
688
+ ```
689
+ connect-src https://api.sparkvault.com wss://ws.sparkvault.com;
690
+ script-src https://cdn.sparkvault.com;
691
+ ```
692
+
693
+ | Directive | Domain | Reason |
694
+ |-----------|--------|--------|
695
+ | `connect-src` | `https://api.sparkvault.com` | API requests (identity verification, config) |
696
+ | `connect-src` | `wss://ws.sparkvault.com` | WebSocket for SparkLink push notifications |
697
+ | `script-src` | `https://cdn.sparkvault.com` | SDK script (if using CDN installation) |
698
+
684
699
  ## Best Practices
685
700
 
686
701
  1. **Initialize Once** — Create the SparkVault instance once when your app loads, not on every login attempt. The SDK preloads configuration for instant modal opening.
@@ -5,7 +5,7 @@
5
5
  * Single responsibility: API calls only.
6
6
  */
7
7
  import type { ResolvedConfig } from '../config';
8
- import type { SdkConfig, TotpSendResponse, TotpVerifyResponse, PasskeyChallengeResponse, PasskeyVerifyResponse, SparkLinkSendResponse, SparkLinkStatusResponse, AuthContext } from './types';
8
+ import type { SdkConfig, TotpSendResponse, TotpVerifyResponse, PasskeyChallengeResponse, PasskeyVerifyResponse, SparkLinkSendResponse, AuthContext } from './types';
9
9
  export declare class IdentityApi {
10
10
  private readonly config;
11
11
  private readonly timeoutMs;
@@ -102,13 +102,9 @@ export declare class IdentityApi {
102
102
  getSocialAuthUrl(provider: string, redirectUri: string, state: string): string;
103
103
  /**
104
104
  * Send SparkLink email for identity verification.
105
- * Includes openerOrigin for postMessage-based completion notification.
105
+ * Includes connectionId for WebSocket push notification of verification result.
106
106
  */
107
- sendSparkLink(email: string, authContext?: AuthContext): Promise<SparkLinkSendResponse>;
108
- /**
109
- * Check SparkLink verification status (polling endpoint)
110
- */
111
- checkSparkLinkStatus(sparkId: string, _authContext?: AuthContext): Promise<SparkLinkStatusResponse>;
107
+ sendSparkLink(email: string, connectionId: string, authContext?: AuthContext): Promise<SparkLinkSendResponse>;
112
108
  }
113
109
  export declare class IdentityApiError extends Error {
114
110
  readonly code: string;
@@ -6,4 +6,4 @@
6
6
  */
7
7
  export { PasskeyHandler, type PasskeyResult, type PasskeyCheckResult } from './passkey-handler';
8
8
  export { TotpHandler, type TotpSendResult, type TotpVerifyResult } from './totp-handler';
9
- export { SparkLinkHandler, type SparkLinkSendResult, type SparkLinkStatusResult } from './sparklink-handler';
9
+ export { SparkLinkHandler, type SparkLinkSendResult } from './sparklink-handler';
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * SparkLink Handler
3
3
  *
4
- * Single responsibility: SparkLink sending and status polling.
5
- * Extracts SparkLink logic from IdentityRenderer for better separation of concerns.
4
+ * Single responsibility: SparkLink sending.
5
+ * Verification is handled via WebSocket push (no polling, no iframe).
6
6
  */
7
7
  import type { IdentityApi } from '../api';
8
8
  import type { VerificationState } from '../state';
@@ -16,18 +16,7 @@ export interface SparkLinkSendResult {
16
16
  error?: string;
17
17
  }
18
18
  /**
19
- * Result of SparkLink status check
20
- */
21
- export interface SparkLinkStatusResult {
22
- verified: boolean;
23
- token?: string;
24
- identity?: string;
25
- identityType?: string;
26
- /** Redirect URL for OIDC/simple mode flows */
27
- redirect?: string;
28
- }
29
- /**
30
- * Handles SparkLink sending and verification polling
19
+ * Handles SparkLink sending
31
20
  */
32
21
  export declare class SparkLinkHandler {
33
22
  private readonly api;
@@ -35,11 +24,7 @@ export declare class SparkLinkHandler {
35
24
  constructor(api: IdentityApi, state: VerificationState);
36
25
  /**
37
26
  * Send SparkLink to the user's email
27
+ * @param connectionId - WebSocket connectionId for push notification
38
28
  */
39
- send(): Promise<SparkLinkSendResult>;
40
- /**
41
- * Check SparkLink verification status
42
- * Called periodically by the renderer to poll for completion
43
- */
44
- checkStatus(sparkId: string): Promise<SparkLinkStatusResult>;
29
+ send(connectionId: string): Promise<SparkLinkSendResult>;
45
30
  }
@@ -23,8 +23,7 @@ export declare class IdentityRenderer {
23
23
  private readonly sparkLinkHandler;
24
24
  private currentView;
25
25
  private focusTimeoutId;
26
- private pollingInterval;
27
- private messageListener;
26
+ private sparkLinkWs;
28
27
  constructor(container: Container, api: IdentityApi, options: VerifyOptions, callbacks: RendererCallbacks);
29
28
  private get recipient();
30
29
  private get identityType();
@@ -88,19 +87,24 @@ export declare class IdentityRenderer {
88
87
  */
89
88
  private handlePasskeyPromptSkip;
90
89
  /**
91
- * Start listening for SparkLink verification completion.
92
- * Uses postMessage as primary mechanism (instant notification from ceremony page),
93
- * with polling as fallback for edge cases where postMessage might fail.
90
+ * Open a WebSocket connection and return the connectionId.
91
+ * The server echoes back the connectionId on init; this is used to route
92
+ * the SparkLink verification result back to this SDK instance.
94
93
  */
95
- private startSparkLinkPolling;
94
+ private openSparkLinkWebSocket;
96
95
  /**
97
- * Handle successful SparkLink verification (from either postMessage or polling).
96
+ * Set up WebSocket message handler for SparkLink verification results.
97
+ * Handles: sparklink_verified, sparklink_failed, sparklink_resent.
98
+ */
99
+ private setupSparkLinkWsHandler;
100
+ /**
101
+ * Handle successful SparkLink verification via WebSocket push.
98
102
  */
99
103
  private handleSparkLinkVerified;
100
104
  /**
101
- * Stop SparkLink polling and message listener.
105
+ * Clean up SparkLink WebSocket connection.
102
106
  */
103
- private stopSparkLinkPolling;
107
+ private cleanupSparkLink;
104
108
  /**
105
109
  * Handle resending SparkLink.
106
110
  */
@@ -113,6 +113,8 @@ export interface SdkConfig {
113
113
  allowedIdentityTypes?: ('email' | 'phone')[];
114
114
  /** Enabled method IDs */
115
115
  methods: MethodId[];
116
+ /** WebSocket domain for SparkLink push notifications (e.g., 'ws.sparkvault.com') */
117
+ wsDomain?: string;
116
118
  }
117
119
  /** Method metadata for SDK rendering (static lookup) */
118
120
  export interface MethodMetadata {
@@ -173,14 +175,6 @@ export interface SparkLinkSendResponse {
173
175
  sparkId: string;
174
176
  expiresAt: number;
175
177
  }
176
- export interface SparkLinkStatusResponse {
177
- verified: boolean;
178
- token?: string;
179
- identity?: string;
180
- identityType?: string;
181
- /** Redirect URL for OIDC/simple mode flows */
182
- redirect?: string;
183
- }
184
178
  export type IdentityViewState = {
185
179
  view: 'loading';
186
180
  } | {
@@ -210,7 +204,6 @@ export type IdentityViewState = {
210
204
  } | {
211
205
  view: 'sparklink-waiting';
212
206
  email: string;
213
- sparkId: string;
214
207
  expiresAt: number;
215
208
  } | {
216
209
  view: 'oauth-pending';
@@ -833,26 +833,16 @@ class IdentityApi {
833
833
  }
834
834
  /**
835
835
  * Send SparkLink email for identity verification.
836
- * Includes openerOrigin for postMessage-based completion notification.
836
+ * Includes connectionId for WebSocket push notification of verification result.
837
837
  */
838
- async sendSparkLink(email, authContext) {
838
+ async sendSparkLink(email, connectionId, authContext) {
839
839
  return this.request('POST', '/sparklink/send', {
840
840
  email,
841
841
  type: 'verify_identity',
842
- // Send opener origin for postMessage on verification completion
843
- // This allows the ceremony page to notify the SDK directly instead of polling
844
- openerOrigin: typeof window !== 'undefined' ? window.location.origin : undefined,
842
+ connection_id: connectionId,
845
843
  ...this.buildAuthContextParams(authContext),
846
844
  });
847
845
  }
848
- /**
849
- * Check SparkLink verification status (polling endpoint)
850
- */
851
- async checkSparkLinkStatus(sparkId, _authContext) {
852
- // For GET requests with auth context, we'd need query params, but the status endpoint
853
- // doesn't need auth context - it's already stored in the sparklink record
854
- return this.request('GET', `/sparklink/status/${sparkId}`);
855
- }
856
846
  }
857
847
  class IdentityApiError extends Error {
858
848
  constructor(message, code, statusCode) {
@@ -1494,7 +1484,7 @@ class TotpHandler {
1494
1484
  success: false,
1495
1485
  newKindling: response.kindling,
1496
1486
  retryAfter: response.retry_after,
1497
- backoffExpires: response.expires_at,
1487
+ backoffExpires: response.retry_after, // When backoff ends (not code expiry)
1498
1488
  error: 'Invalid code. Please try again.',
1499
1489
  };
1500
1490
  }
@@ -1511,11 +1501,11 @@ class TotpHandler {
1511
1501
  /**
1512
1502
  * SparkLink Handler
1513
1503
  *
1514
- * Single responsibility: SparkLink sending and status polling.
1515
- * Extracts SparkLink logic from IdentityRenderer for better separation of concerns.
1504
+ * Single responsibility: SparkLink sending.
1505
+ * Verification is handled via WebSocket push (no polling, no iframe).
1516
1506
  */
1517
1507
  /**
1518
- * Handles SparkLink sending and verification polling
1508
+ * Handles SparkLink sending
1519
1509
  */
1520
1510
  class SparkLinkHandler {
1521
1511
  constructor(api, state) {
@@ -1524,10 +1514,11 @@ class SparkLinkHandler {
1524
1514
  }
1525
1515
  /**
1526
1516
  * Send SparkLink to the user's email
1517
+ * @param connectionId - WebSocket connectionId for push notification
1527
1518
  */
1528
- async send() {
1519
+ async send(connectionId) {
1529
1520
  try {
1530
- const result = await this.api.sendSparkLink(this.state.recipient, this.state.authContext);
1521
+ const result = await this.api.sendSparkLink(this.state.recipient, connectionId, this.state.authContext);
1531
1522
  return {
1532
1523
  success: true,
1533
1524
  sparkId: result.sparkId,
@@ -1542,26 +1533,6 @@ class SparkLinkHandler {
1542
1533
  };
1543
1534
  }
1544
1535
  }
1545
- /**
1546
- * Check SparkLink verification status
1547
- * Called periodically by the renderer to poll for completion
1548
- */
1549
- async checkStatus(sparkId) {
1550
- try {
1551
- const result = await this.api.checkSparkLinkStatus(sparkId);
1552
- return {
1553
- verified: result.verified,
1554
- token: result.token,
1555
- identity: result.identity,
1556
- identityType: result.identityType,
1557
- redirect: result.redirect,
1558
- };
1559
- }
1560
- catch {
1561
- // On error, return not verified (polling will continue)
1562
- return { verified: false };
1563
- }
1564
- }
1565
1536
  }
1566
1537
 
1567
1538
  /**
@@ -4234,7 +4205,7 @@ class SparkLinkWaitingView {
4234
4205
  * Switch to expired state - static icon, no animation, helpful message
4235
4206
  */
4236
4207
  showExpiredState() {
4237
- // Notify renderer to stop polling
4208
+ // Notify renderer to clean up WebSocket connection
4238
4209
  this.props.onExpired();
4239
4210
  // Update waiting section: replace spinner with expired icon
4240
4211
  if (this.waitingSection) {
@@ -4381,9 +4352,8 @@ class IdentityRenderer {
4381
4352
  // View management
4382
4353
  this.currentView = null;
4383
4354
  this.focusTimeoutId = null;
4384
- // SparkLink polling and postMessage listener
4385
- this.pollingInterval = null;
4386
- this.messageListener = null;
4355
+ // SparkLink WebSocket
4356
+ this.sparkLinkWs = null;
4387
4357
  this.container = container;
4388
4358
  this.api = api;
4389
4359
  this.options = options;
@@ -4482,7 +4452,7 @@ class IdentityRenderer {
4482
4452
  clearTimeout(this.focusTimeoutId);
4483
4453
  this.focusTimeoutId = null;
4484
4454
  }
4485
- this.stopSparkLinkPolling();
4455
+ this.cleanupSparkLink();
4486
4456
  this.destroyCurrentView();
4487
4457
  this.container.destroy();
4488
4458
  }
@@ -4587,7 +4557,7 @@ class IdentityRenderer {
4587
4557
  onResend: () => this.handleSparkLinkResend(),
4588
4558
  onFallback: () => this.handleSparkLinkFallback(),
4589
4559
  onBack: () => this.showMethodSelect(),
4590
- onExpired: () => this.stopSparkLinkPolling(),
4560
+ onExpired: () => this.cleanupSparkLink(),
4591
4561
  });
4592
4562
  case 'oauth-pending':
4593
4563
  return new LoadingView({ message: `Connecting to ${state.provider}...` });
@@ -4717,8 +4687,20 @@ class IdentityRenderer {
4717
4687
  }
4718
4688
  case 'sparklink': {
4719
4689
  this.setState({ view: 'loading' });
4720
- const result = await this.sparkLinkHandler.send();
4690
+ // Open WebSocket and get connectionId before sending
4691
+ const connectionId = await this.openSparkLinkWebSocket();
4692
+ if (!connectionId) {
4693
+ this.cleanupSparkLink();
4694
+ this.setState({
4695
+ view: 'error',
4696
+ message: 'Failed to establish connection',
4697
+ code: 'sparklink_ws_failed',
4698
+ });
4699
+ return;
4700
+ }
4701
+ const result = await this.sparkLinkHandler.send(connectionId);
4721
4702
  if (!result.success) {
4703
+ this.cleanupSparkLink();
4722
4704
  this.setState({
4723
4705
  view: 'error',
4724
4706
  message: result.error ?? 'Failed to send SparkLink',
@@ -4729,10 +4711,8 @@ class IdentityRenderer {
4729
4711
  this.setState({
4730
4712
  view: 'sparklink-waiting',
4731
4713
  email: this.recipient,
4732
- sparkId: result.sparkId,
4733
4714
  expiresAt: result.expiresAt,
4734
4715
  });
4735
- this.startSparkLinkPolling(result.sparkId);
4736
4716
  break;
4737
4717
  }
4738
4718
  case 'social': {
@@ -5018,51 +4998,119 @@ class IdentityRenderer {
5018
4998
  this.callbacks.onSuccess(pendingResult);
5019
4999
  }
5020
5000
  /**
5021
- * Start listening for SparkLink verification completion.
5022
- * Uses postMessage as primary mechanism (instant notification from ceremony page),
5023
- * with polling as fallback for edge cases where postMessage might fail.
5001
+ * Open a WebSocket connection and return the connectionId.
5002
+ * The server echoes back the connectionId on init; this is used to route
5003
+ * the SparkLink verification result back to this SDK instance.
5024
5004
  */
5025
- startSparkLinkPolling(sparkId) {
5026
- this.stopSparkLinkPolling();
5027
- // Primary: Listen for postMessage from ceremony page
5028
- // This is faster and more reliable than polling
5029
- this.messageListener = (event) => {
5030
- // Validate message structure and type
5031
- if (!event.data || typeof event.data !== 'object')
5032
- return;
5033
- if (event.data.type !== 'sparklink_verified')
5034
- return;
5035
- const { token, identity, identityType, redirect } = event.data;
5036
- // Validate required fields
5037
- if (!token || !identity)
5005
+ openSparkLinkWebSocket() {
5006
+ this.cleanupSparkLink();
5007
+ const wsDomain = this.sdkConfig?.wsDomain || 'ws.sparkvault.com';
5008
+ const wsUrl = `wss://${wsDomain}`;
5009
+ return new Promise((resolve) => {
5010
+ let resolved = false;
5011
+ const ws = new WebSocket(wsUrl);
5012
+ this.sparkLinkWs = ws;
5013
+ const timeout = setTimeout(() => {
5014
+ if (!resolved) {
5015
+ resolved = true;
5016
+ ws.close();
5017
+ resolve(null);
5018
+ }
5019
+ }, 10000);
5020
+ ws.onopen = () => {
5021
+ try {
5022
+ ws.send(JSON.stringify({ action: 'init' }));
5023
+ }
5024
+ catch {
5025
+ if (!resolved) {
5026
+ resolved = true;
5027
+ clearTimeout(timeout);
5028
+ resolve(null);
5029
+ }
5030
+ }
5031
+ };
5032
+ ws.onmessage = (event) => {
5033
+ let data;
5034
+ try {
5035
+ data = JSON.parse(event.data);
5036
+ }
5037
+ catch {
5038
+ return;
5039
+ }
5040
+ if (!resolved && data.type === 'connection' && typeof data.connectionId === 'string') {
5041
+ resolved = true;
5042
+ clearTimeout(timeout);
5043
+ this.setupSparkLinkWsHandler(ws);
5044
+ resolve(data.connectionId);
5045
+ return;
5046
+ }
5047
+ };
5048
+ ws.onerror = () => {
5049
+ if (!resolved) {
5050
+ resolved = true;
5051
+ clearTimeout(timeout);
5052
+ resolve(null);
5053
+ }
5054
+ };
5055
+ ws.onclose = () => {
5056
+ if (!resolved) {
5057
+ resolved = true;
5058
+ clearTimeout(timeout);
5059
+ resolve(null);
5060
+ }
5061
+ };
5062
+ });
5063
+ }
5064
+ /**
5065
+ * Set up WebSocket message handler for SparkLink verification results.
5066
+ * Handles: sparklink_verified, sparklink_failed, sparklink_resent.
5067
+ */
5068
+ setupSparkLinkWsHandler(ws) {
5069
+ ws.onmessage = (event) => {
5070
+ let data;
5071
+ try {
5072
+ data = JSON.parse(event.data);
5073
+ }
5074
+ catch {
5038
5075
  return;
5039
- this.handleSparkLinkVerified({
5040
- token,
5041
- identity,
5042
- identityType: identityType || 'email',
5043
- redirect,
5044
- });
5045
- };
5046
- window.addEventListener('message', this.messageListener);
5047
- // Fallback: Poll status endpoint every 2 seconds
5048
- // This catches cases where postMessage might not work (popup blockers, etc)
5049
- this.pollingInterval = setInterval(async () => {
5050
- const status = await this.sparkLinkHandler.checkStatus(sparkId);
5051
- if (status.verified && status.token && status.identity) {
5052
- this.handleSparkLinkVerified({
5053
- token: status.token,
5054
- identity: status.identity,
5055
- identityType: status.identityType || 'email',
5056
- redirect: status.redirect,
5057
- });
5058
5076
  }
5059
- }, 2000);
5077
+ switch (data.type) {
5078
+ case 'sparklink_verified':
5079
+ this.handleSparkLinkVerified({
5080
+ token: data.token,
5081
+ identity: data.identity,
5082
+ identityType: data.identityType || 'email',
5083
+ });
5084
+ break;
5085
+ case 'sparklink_failed':
5086
+ this.cleanupSparkLink();
5087
+ this.setState({
5088
+ view: 'error',
5089
+ message: 'Verification failed. Please try again or choose a different method.',
5090
+ code: 'sparklink_failed',
5091
+ });
5092
+ break;
5093
+ case 'sparklink_resent':
5094
+ // Server auto-resent a new link — update countdown timer
5095
+ if (typeof data.expiresAt === 'number') {
5096
+ this.setState({
5097
+ view: 'sparklink-waiting',
5098
+ email: this.recipient,
5099
+ expiresAt: data.expiresAt,
5100
+ });
5101
+ }
5102
+ break;
5103
+ }
5104
+ };
5105
+ ws.onclose = () => {
5106
+ this.sparkLinkWs = null;
5107
+ };
5060
5108
  }
5061
5109
  /**
5062
- * Handle successful SparkLink verification (from either postMessage or polling).
5110
+ * Handle successful SparkLink verification via WebSocket push.
5063
5111
  */
5064
5112
  async handleSparkLinkVerified(result) {
5065
- this.stopSparkLinkPolling();
5113
+ this.cleanupSparkLink();
5066
5114
  // Handle redirect for OIDC/simple mode flows
5067
5115
  if (result.redirect) {
5068
5116
  this.close();
@@ -5082,39 +5130,54 @@ class IdentityRenderer {
5082
5130
  this.callbacks.onSuccess(result);
5083
5131
  }
5084
5132
  /**
5085
- * Stop SparkLink polling and message listener.
5133
+ * Clean up SparkLink WebSocket connection.
5086
5134
  */
5087
- stopSparkLinkPolling() {
5088
- if (this.pollingInterval) {
5089
- clearInterval(this.pollingInterval);
5090
- this.pollingInterval = null;
5091
- }
5092
- if (this.messageListener) {
5093
- window.removeEventListener('message', this.messageListener);
5094
- this.messageListener = null;
5135
+ cleanupSparkLink() {
5136
+ if (this.sparkLinkWs) {
5137
+ this.sparkLinkWs.onmessage = null;
5138
+ this.sparkLinkWs.onclose = null;
5139
+ this.sparkLinkWs.onerror = null;
5140
+ this.sparkLinkWs.close();
5141
+ this.sparkLinkWs = null;
5095
5142
  }
5096
5143
  }
5097
5144
  /**
5098
5145
  * Handle resending SparkLink.
5099
5146
  */
5100
5147
  async handleSparkLinkResend() {
5101
- const result = await this.sparkLinkHandler.send();
5102
- if (result.success && result.sparkId) {
5103
- this.stopSparkLinkPolling();
5148
+ // Open new WebSocket for the resend
5149
+ const connectionId = await this.openSparkLinkWebSocket();
5150
+ if (!connectionId) {
5151
+ this.cleanupSparkLink();
5152
+ this.setState({
5153
+ view: 'error',
5154
+ message: 'Unable to establish a secure connection. Please try again or choose a different method.',
5155
+ code: 'sparklink_ws_failed',
5156
+ });
5157
+ return;
5158
+ }
5159
+ const result = await this.sparkLinkHandler.send(connectionId);
5160
+ if (result.success) {
5104
5161
  this.setState({
5105
5162
  view: 'sparklink-waiting',
5106
5163
  email: this.recipient,
5107
- sparkId: result.sparkId,
5108
5164
  expiresAt: result.expiresAt,
5109
5165
  });
5110
- this.startSparkLinkPolling(result.sparkId);
5166
+ }
5167
+ else {
5168
+ this.cleanupSparkLink();
5169
+ this.setState({
5170
+ view: 'error',
5171
+ message: result.error ?? 'Failed to resend verification link. Please try again.',
5172
+ code: 'sparklink_resend_failed',
5173
+ });
5111
5174
  }
5112
5175
  }
5113
5176
  /**
5114
5177
  * Handle fallback from SparkLink to TOTP verification code.
5115
5178
  */
5116
5179
  async handleSparkLinkFallback() {
5117
- this.stopSparkLinkPolling();
5180
+ this.cleanupSparkLink();
5118
5181
  this.setState({ view: 'loading' });
5119
5182
  const result = await this.totpHandler.send('email');
5120
5183
  if (!result.success) {