@sparkvault/sdk 1.11.2 → 1.21.4

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
  /**
@@ -1690,6 +1661,7 @@ function getStyles(options) {
1690
1661
  padding: 14px 20px;
1691
1662
  border-bottom: 1px solid ${tokens.border};
1692
1663
  background: ${tokens.bg};
1664
+ border-radius: 16px 16px 0 0;
1693
1665
  flex-shrink: 0;
1694
1666
  }
1695
1667
 
@@ -4233,7 +4205,7 @@ class SparkLinkWaitingView {
4233
4205
  * Switch to expired state - static icon, no animation, helpful message
4234
4206
  */
4235
4207
  showExpiredState() {
4236
- // Notify renderer to stop polling
4208
+ // Notify renderer to clean up WebSocket connection
4237
4209
  this.props.onExpired();
4238
4210
  // Update waiting section: replace spinner with expired icon
4239
4211
  if (this.waitingSection) {
@@ -4380,9 +4352,8 @@ class IdentityRenderer {
4380
4352
  // View management
4381
4353
  this.currentView = null;
4382
4354
  this.focusTimeoutId = null;
4383
- // SparkLink polling and postMessage listener
4384
- this.pollingInterval = null;
4385
- this.messageListener = null;
4355
+ // SparkLink WebSocket
4356
+ this.sparkLinkWs = null;
4386
4357
  this.container = container;
4387
4358
  this.api = api;
4388
4359
  this.options = options;
@@ -4481,7 +4452,7 @@ class IdentityRenderer {
4481
4452
  clearTimeout(this.focusTimeoutId);
4482
4453
  this.focusTimeoutId = null;
4483
4454
  }
4484
- this.stopSparkLinkPolling();
4455
+ this.cleanupSparkLink();
4485
4456
  this.destroyCurrentView();
4486
4457
  this.container.destroy();
4487
4458
  }
@@ -4586,7 +4557,7 @@ class IdentityRenderer {
4586
4557
  onResend: () => this.handleSparkLinkResend(),
4587
4558
  onFallback: () => this.handleSparkLinkFallback(),
4588
4559
  onBack: () => this.showMethodSelect(),
4589
- onExpired: () => this.stopSparkLinkPolling(),
4560
+ onExpired: () => this.cleanupSparkLink(),
4590
4561
  });
4591
4562
  case 'oauth-pending':
4592
4563
  return new LoadingView({ message: `Connecting to ${state.provider}...` });
@@ -4716,8 +4687,20 @@ class IdentityRenderer {
4716
4687
  }
4717
4688
  case 'sparklink': {
4718
4689
  this.setState({ view: 'loading' });
4719
- 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);
4720
4702
  if (!result.success) {
4703
+ this.cleanupSparkLink();
4721
4704
  this.setState({
4722
4705
  view: 'error',
4723
4706
  message: result.error ?? 'Failed to send SparkLink',
@@ -4728,10 +4711,8 @@ class IdentityRenderer {
4728
4711
  this.setState({
4729
4712
  view: 'sparklink-waiting',
4730
4713
  email: this.recipient,
4731
- sparkId: result.sparkId,
4732
4714
  expiresAt: result.expiresAt,
4733
4715
  });
4734
- this.startSparkLinkPolling(result.sparkId);
4735
4716
  break;
4736
4717
  }
4737
4718
  case 'social': {
@@ -5017,51 +4998,110 @@ class IdentityRenderer {
5017
4998
  this.callbacks.onSuccess(pendingResult);
5018
4999
  }
5019
5000
  /**
5020
- * Start listening for SparkLink verification completion.
5021
- * Uses postMessage as primary mechanism (instant notification from ceremony page),
5022
- * 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.
5023
5004
  */
5024
- startSparkLinkPolling(sparkId) {
5025
- this.stopSparkLinkPolling();
5026
- // Primary: Listen for postMessage from ceremony page
5027
- // This is faster and more reliable than polling
5028
- this.messageListener = (event) => {
5029
- // Validate message structure and type
5030
- if (!event.data || typeof event.data !== 'object')
5031
- return;
5032
- if (event.data.type !== 'sparklink_verified')
5033
- return;
5034
- const { token, identity, identityType, redirect } = event.data;
5035
- // Validate required fields
5036
- 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
+ ws.send(JSON.stringify({ action: 'init' }));
5022
+ };
5023
+ ws.onmessage = (event) => {
5024
+ let data;
5025
+ try {
5026
+ data = JSON.parse(event.data);
5027
+ }
5028
+ catch {
5029
+ return;
5030
+ }
5031
+ if (!resolved && data.type === 'connection' && typeof data.connectionId === 'string') {
5032
+ resolved = true;
5033
+ clearTimeout(timeout);
5034
+ this.setupSparkLinkWsHandler(ws);
5035
+ resolve(data.connectionId);
5036
+ return;
5037
+ }
5038
+ };
5039
+ ws.onerror = () => {
5040
+ if (!resolved) {
5041
+ resolved = true;
5042
+ clearTimeout(timeout);
5043
+ resolve(null);
5044
+ }
5045
+ };
5046
+ ws.onclose = () => {
5047
+ if (!resolved) {
5048
+ resolved = true;
5049
+ clearTimeout(timeout);
5050
+ resolve(null);
5051
+ }
5052
+ };
5053
+ });
5054
+ }
5055
+ /**
5056
+ * Set up WebSocket message handler for SparkLink verification results.
5057
+ * Handles: sparklink_verified, sparklink_failed, sparklink_resent.
5058
+ */
5059
+ setupSparkLinkWsHandler(ws) {
5060
+ ws.onmessage = (event) => {
5061
+ let data;
5062
+ try {
5063
+ data = JSON.parse(event.data);
5064
+ }
5065
+ catch {
5037
5066
  return;
5038
- this.handleSparkLinkVerified({
5039
- token,
5040
- identity,
5041
- identityType: identityType || 'email',
5042
- redirect,
5043
- });
5044
- };
5045
- window.addEventListener('message', this.messageListener);
5046
- // Fallback: Poll status endpoint every 2 seconds
5047
- // This catches cases where postMessage might not work (popup blockers, etc)
5048
- this.pollingInterval = setInterval(async () => {
5049
- const status = await this.sparkLinkHandler.checkStatus(sparkId);
5050
- if (status.verified && status.token && status.identity) {
5051
- this.handleSparkLinkVerified({
5052
- token: status.token,
5053
- identity: status.identity,
5054
- identityType: status.identityType || 'email',
5055
- redirect: status.redirect,
5056
- });
5057
5067
  }
5058
- }, 2000);
5068
+ switch (data.type) {
5069
+ case 'sparklink_verified':
5070
+ this.handleSparkLinkVerified({
5071
+ token: data.token,
5072
+ identity: data.identity,
5073
+ identityType: data.identityType || 'email',
5074
+ });
5075
+ break;
5076
+ case 'sparklink_failed':
5077
+ this.cleanupSparkLink();
5078
+ this.setState({
5079
+ view: 'error',
5080
+ message: 'Verification failed. Please try again or choose a different method.',
5081
+ code: 'sparklink_failed',
5082
+ });
5083
+ break;
5084
+ case 'sparklink_resent':
5085
+ // Server auto-resent a new link — update countdown timer
5086
+ if (typeof data.expiresAt === 'number') {
5087
+ this.setState({
5088
+ view: 'sparklink-waiting',
5089
+ email: this.recipient,
5090
+ expiresAt: data.expiresAt,
5091
+ });
5092
+ }
5093
+ break;
5094
+ }
5095
+ };
5096
+ ws.onclose = () => {
5097
+ this.sparkLinkWs = null;
5098
+ };
5059
5099
  }
5060
5100
  /**
5061
- * Handle successful SparkLink verification (from either postMessage or polling).
5101
+ * Handle successful SparkLink verification via WebSocket push.
5062
5102
  */
5063
5103
  async handleSparkLinkVerified(result) {
5064
- this.stopSparkLinkPolling();
5104
+ this.cleanupSparkLink();
5065
5105
  // Handle redirect for OIDC/simple mode flows
5066
5106
  if (result.redirect) {
5067
5107
  this.close();
@@ -5081,39 +5121,44 @@ class IdentityRenderer {
5081
5121
  this.callbacks.onSuccess(result);
5082
5122
  }
5083
5123
  /**
5084
- * Stop SparkLink polling and message listener.
5124
+ * Clean up SparkLink WebSocket connection.
5085
5125
  */
5086
- stopSparkLinkPolling() {
5087
- if (this.pollingInterval) {
5088
- clearInterval(this.pollingInterval);
5089
- this.pollingInterval = null;
5090
- }
5091
- if (this.messageListener) {
5092
- window.removeEventListener('message', this.messageListener);
5093
- this.messageListener = null;
5126
+ cleanupSparkLink() {
5127
+ if (this.sparkLinkWs) {
5128
+ this.sparkLinkWs.onmessage = null;
5129
+ this.sparkLinkWs.onclose = null;
5130
+ this.sparkLinkWs.onerror = null;
5131
+ this.sparkLinkWs.close();
5132
+ this.sparkLinkWs = null;
5094
5133
  }
5095
5134
  }
5096
5135
  /**
5097
5136
  * Handle resending SparkLink.
5098
5137
  */
5099
5138
  async handleSparkLinkResend() {
5100
- const result = await this.sparkLinkHandler.send();
5101
- if (result.success && result.sparkId) {
5102
- this.stopSparkLinkPolling();
5139
+ // Open new WebSocket for the resend
5140
+ const connectionId = await this.openSparkLinkWebSocket();
5141
+ if (!connectionId) {
5142
+ this.cleanupSparkLink();
5143
+ return;
5144
+ }
5145
+ const result = await this.sparkLinkHandler.send(connectionId);
5146
+ if (result.success) {
5103
5147
  this.setState({
5104
5148
  view: 'sparklink-waiting',
5105
5149
  email: this.recipient,
5106
- sparkId: result.sparkId,
5107
5150
  expiresAt: result.expiresAt,
5108
5151
  });
5109
- this.startSparkLinkPolling(result.sparkId);
5152
+ }
5153
+ else {
5154
+ this.cleanupSparkLink();
5110
5155
  }
5111
5156
  }
5112
5157
  /**
5113
5158
  * Handle fallback from SparkLink to TOTP verification code.
5114
5159
  */
5115
5160
  async handleSparkLinkFallback() {
5116
- this.stopSparkLinkPolling();
5161
+ this.cleanupSparkLink();
5117
5162
  this.setState({ view: 'loading' });
5118
5163
  const result = await this.totpHandler.send('email');
5119
5164
  if (!result.success) {