@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.
@@ -829,26 +829,16 @@ class IdentityApi {
829
829
  }
830
830
  /**
831
831
  * Send SparkLink email for identity verification.
832
- * Includes openerOrigin for postMessage-based completion notification.
832
+ * Includes connectionId for WebSocket push notification of verification result.
833
833
  */
834
- async sendSparkLink(email, authContext) {
834
+ async sendSparkLink(email, connectionId, authContext) {
835
835
  return this.request('POST', '/sparklink/send', {
836
836
  email,
837
837
  type: 'verify_identity',
838
- // Send opener origin for postMessage on verification completion
839
- // This allows the ceremony page to notify the SDK directly instead of polling
840
- openerOrigin: typeof window !== 'undefined' ? window.location.origin : undefined,
838
+ connection_id: connectionId,
841
839
  ...this.buildAuthContextParams(authContext),
842
840
  });
843
841
  }
844
- /**
845
- * Check SparkLink verification status (polling endpoint)
846
- */
847
- async checkSparkLinkStatus(sparkId, _authContext) {
848
- // For GET requests with auth context, we'd need query params, but the status endpoint
849
- // doesn't need auth context - it's already stored in the sparklink record
850
- return this.request('GET', `/sparklink/status/${sparkId}`);
851
- }
852
842
  }
853
843
  class IdentityApiError extends Error {
854
844
  constructor(message, code, statusCode) {
@@ -1490,7 +1480,7 @@ class TotpHandler {
1490
1480
  success: false,
1491
1481
  newKindling: response.kindling,
1492
1482
  retryAfter: response.retry_after,
1493
- backoffExpires: response.expires_at,
1483
+ backoffExpires: response.retry_after, // When backoff ends (not code expiry)
1494
1484
  error: 'Invalid code. Please try again.',
1495
1485
  };
1496
1486
  }
@@ -1507,11 +1497,11 @@ class TotpHandler {
1507
1497
  /**
1508
1498
  * SparkLink Handler
1509
1499
  *
1510
- * Single responsibility: SparkLink sending and status polling.
1511
- * Extracts SparkLink logic from IdentityRenderer for better separation of concerns.
1500
+ * Single responsibility: SparkLink sending.
1501
+ * Verification is handled via WebSocket push (no polling, no iframe).
1512
1502
  */
1513
1503
  /**
1514
- * Handles SparkLink sending and verification polling
1504
+ * Handles SparkLink sending
1515
1505
  */
1516
1506
  class SparkLinkHandler {
1517
1507
  constructor(api, state) {
@@ -1520,10 +1510,11 @@ class SparkLinkHandler {
1520
1510
  }
1521
1511
  /**
1522
1512
  * Send SparkLink to the user's email
1513
+ * @param connectionId - WebSocket connectionId for push notification
1523
1514
  */
1524
- async send() {
1515
+ async send(connectionId) {
1525
1516
  try {
1526
- const result = await this.api.sendSparkLink(this.state.recipient, this.state.authContext);
1517
+ const result = await this.api.sendSparkLink(this.state.recipient, connectionId, this.state.authContext);
1527
1518
  return {
1528
1519
  success: true,
1529
1520
  sparkId: result.sparkId,
@@ -1538,26 +1529,6 @@ class SparkLinkHandler {
1538
1529
  };
1539
1530
  }
1540
1531
  }
1541
- /**
1542
- * Check SparkLink verification status
1543
- * Called periodically by the renderer to poll for completion
1544
- */
1545
- async checkStatus(sparkId) {
1546
- try {
1547
- const result = await this.api.checkSparkLinkStatus(sparkId);
1548
- return {
1549
- verified: result.verified,
1550
- token: result.token,
1551
- identity: result.identity,
1552
- identityType: result.identityType,
1553
- redirect: result.redirect,
1554
- };
1555
- }
1556
- catch {
1557
- // On error, return not verified (polling will continue)
1558
- return { verified: false };
1559
- }
1560
- }
1561
1532
  }
1562
1533
 
1563
1534
  /**
@@ -4230,7 +4201,7 @@ class SparkLinkWaitingView {
4230
4201
  * Switch to expired state - static icon, no animation, helpful message
4231
4202
  */
4232
4203
  showExpiredState() {
4233
- // Notify renderer to stop polling
4204
+ // Notify renderer to clean up WebSocket connection
4234
4205
  this.props.onExpired();
4235
4206
  // Update waiting section: replace spinner with expired icon
4236
4207
  if (this.waitingSection) {
@@ -4377,9 +4348,8 @@ class IdentityRenderer {
4377
4348
  // View management
4378
4349
  this.currentView = null;
4379
4350
  this.focusTimeoutId = null;
4380
- // SparkLink polling and postMessage listener
4381
- this.pollingInterval = null;
4382
- this.messageListener = null;
4351
+ // SparkLink WebSocket
4352
+ this.sparkLinkWs = null;
4383
4353
  this.container = container;
4384
4354
  this.api = api;
4385
4355
  this.options = options;
@@ -4478,7 +4448,7 @@ class IdentityRenderer {
4478
4448
  clearTimeout(this.focusTimeoutId);
4479
4449
  this.focusTimeoutId = null;
4480
4450
  }
4481
- this.stopSparkLinkPolling();
4451
+ this.cleanupSparkLink();
4482
4452
  this.destroyCurrentView();
4483
4453
  this.container.destroy();
4484
4454
  }
@@ -4583,7 +4553,7 @@ class IdentityRenderer {
4583
4553
  onResend: () => this.handleSparkLinkResend(),
4584
4554
  onFallback: () => this.handleSparkLinkFallback(),
4585
4555
  onBack: () => this.showMethodSelect(),
4586
- onExpired: () => this.stopSparkLinkPolling(),
4556
+ onExpired: () => this.cleanupSparkLink(),
4587
4557
  });
4588
4558
  case 'oauth-pending':
4589
4559
  return new LoadingView({ message: `Connecting to ${state.provider}...` });
@@ -4713,8 +4683,20 @@ class IdentityRenderer {
4713
4683
  }
4714
4684
  case 'sparklink': {
4715
4685
  this.setState({ view: 'loading' });
4716
- const result = await this.sparkLinkHandler.send();
4686
+ // Open WebSocket and get connectionId before sending
4687
+ const connectionId = await this.openSparkLinkWebSocket();
4688
+ if (!connectionId) {
4689
+ this.cleanupSparkLink();
4690
+ this.setState({
4691
+ view: 'error',
4692
+ message: 'Failed to establish connection',
4693
+ code: 'sparklink_ws_failed',
4694
+ });
4695
+ return;
4696
+ }
4697
+ const result = await this.sparkLinkHandler.send(connectionId);
4717
4698
  if (!result.success) {
4699
+ this.cleanupSparkLink();
4718
4700
  this.setState({
4719
4701
  view: 'error',
4720
4702
  message: result.error ?? 'Failed to send SparkLink',
@@ -4725,10 +4707,8 @@ class IdentityRenderer {
4725
4707
  this.setState({
4726
4708
  view: 'sparklink-waiting',
4727
4709
  email: this.recipient,
4728
- sparkId: result.sparkId,
4729
4710
  expiresAt: result.expiresAt,
4730
4711
  });
4731
- this.startSparkLinkPolling(result.sparkId);
4732
4712
  break;
4733
4713
  }
4734
4714
  case 'social': {
@@ -5014,51 +4994,119 @@ class IdentityRenderer {
5014
4994
  this.callbacks.onSuccess(pendingResult);
5015
4995
  }
5016
4996
  /**
5017
- * Start listening for SparkLink verification completion.
5018
- * Uses postMessage as primary mechanism (instant notification from ceremony page),
5019
- * with polling as fallback for edge cases where postMessage might fail.
4997
+ * Open a WebSocket connection and return the connectionId.
4998
+ * The server echoes back the connectionId on init; this is used to route
4999
+ * the SparkLink verification result back to this SDK instance.
5020
5000
  */
5021
- startSparkLinkPolling(sparkId) {
5022
- this.stopSparkLinkPolling();
5023
- // Primary: Listen for postMessage from ceremony page
5024
- // This is faster and more reliable than polling
5025
- this.messageListener = (event) => {
5026
- // Validate message structure and type
5027
- if (!event.data || typeof event.data !== 'object')
5028
- return;
5029
- if (event.data.type !== 'sparklink_verified')
5030
- return;
5031
- const { token, identity, identityType, redirect } = event.data;
5032
- // Validate required fields
5033
- if (!token || !identity)
5001
+ openSparkLinkWebSocket() {
5002
+ this.cleanupSparkLink();
5003
+ const wsDomain = this.sdkConfig?.wsDomain || 'ws.sparkvault.com';
5004
+ const wsUrl = `wss://${wsDomain}`;
5005
+ return new Promise((resolve) => {
5006
+ let resolved = false;
5007
+ const ws = new WebSocket(wsUrl);
5008
+ this.sparkLinkWs = ws;
5009
+ const timeout = setTimeout(() => {
5010
+ if (!resolved) {
5011
+ resolved = true;
5012
+ ws.close();
5013
+ resolve(null);
5014
+ }
5015
+ }, 10000);
5016
+ ws.onopen = () => {
5017
+ try {
5018
+ ws.send(JSON.stringify({ action: 'init' }));
5019
+ }
5020
+ catch {
5021
+ if (!resolved) {
5022
+ resolved = true;
5023
+ clearTimeout(timeout);
5024
+ resolve(null);
5025
+ }
5026
+ }
5027
+ };
5028
+ ws.onmessage = (event) => {
5029
+ let data;
5030
+ try {
5031
+ data = JSON.parse(event.data);
5032
+ }
5033
+ catch {
5034
+ return;
5035
+ }
5036
+ if (!resolved && data.type === 'connection' && typeof data.connectionId === 'string') {
5037
+ resolved = true;
5038
+ clearTimeout(timeout);
5039
+ this.setupSparkLinkWsHandler(ws);
5040
+ resolve(data.connectionId);
5041
+ return;
5042
+ }
5043
+ };
5044
+ ws.onerror = () => {
5045
+ if (!resolved) {
5046
+ resolved = true;
5047
+ clearTimeout(timeout);
5048
+ resolve(null);
5049
+ }
5050
+ };
5051
+ ws.onclose = () => {
5052
+ if (!resolved) {
5053
+ resolved = true;
5054
+ clearTimeout(timeout);
5055
+ resolve(null);
5056
+ }
5057
+ };
5058
+ });
5059
+ }
5060
+ /**
5061
+ * Set up WebSocket message handler for SparkLink verification results.
5062
+ * Handles: sparklink_verified, sparklink_failed, sparklink_resent.
5063
+ */
5064
+ setupSparkLinkWsHandler(ws) {
5065
+ ws.onmessage = (event) => {
5066
+ let data;
5067
+ try {
5068
+ data = JSON.parse(event.data);
5069
+ }
5070
+ catch {
5034
5071
  return;
5035
- this.handleSparkLinkVerified({
5036
- token,
5037
- identity,
5038
- identityType: identityType || 'email',
5039
- redirect,
5040
- });
5041
- };
5042
- window.addEventListener('message', this.messageListener);
5043
- // Fallback: Poll status endpoint every 2 seconds
5044
- // This catches cases where postMessage might not work (popup blockers, etc)
5045
- this.pollingInterval = setInterval(async () => {
5046
- const status = await this.sparkLinkHandler.checkStatus(sparkId);
5047
- if (status.verified && status.token && status.identity) {
5048
- this.handleSparkLinkVerified({
5049
- token: status.token,
5050
- identity: status.identity,
5051
- identityType: status.identityType || 'email',
5052
- redirect: status.redirect,
5053
- });
5054
5072
  }
5055
- }, 2000);
5073
+ switch (data.type) {
5074
+ case 'sparklink_verified':
5075
+ this.handleSparkLinkVerified({
5076
+ token: data.token,
5077
+ identity: data.identity,
5078
+ identityType: data.identityType || 'email',
5079
+ });
5080
+ break;
5081
+ case 'sparklink_failed':
5082
+ this.cleanupSparkLink();
5083
+ this.setState({
5084
+ view: 'error',
5085
+ message: 'Verification failed. Please try again or choose a different method.',
5086
+ code: 'sparklink_failed',
5087
+ });
5088
+ break;
5089
+ case 'sparklink_resent':
5090
+ // Server auto-resent a new link — update countdown timer
5091
+ if (typeof data.expiresAt === 'number') {
5092
+ this.setState({
5093
+ view: 'sparklink-waiting',
5094
+ email: this.recipient,
5095
+ expiresAt: data.expiresAt,
5096
+ });
5097
+ }
5098
+ break;
5099
+ }
5100
+ };
5101
+ ws.onclose = () => {
5102
+ this.sparkLinkWs = null;
5103
+ };
5056
5104
  }
5057
5105
  /**
5058
- * Handle successful SparkLink verification (from either postMessage or polling).
5106
+ * Handle successful SparkLink verification via WebSocket push.
5059
5107
  */
5060
5108
  async handleSparkLinkVerified(result) {
5061
- this.stopSparkLinkPolling();
5109
+ this.cleanupSparkLink();
5062
5110
  // Handle redirect for OIDC/simple mode flows
5063
5111
  if (result.redirect) {
5064
5112
  this.close();
@@ -5078,39 +5126,54 @@ class IdentityRenderer {
5078
5126
  this.callbacks.onSuccess(result);
5079
5127
  }
5080
5128
  /**
5081
- * Stop SparkLink polling and message listener.
5129
+ * Clean up SparkLink WebSocket connection.
5082
5130
  */
5083
- stopSparkLinkPolling() {
5084
- if (this.pollingInterval) {
5085
- clearInterval(this.pollingInterval);
5086
- this.pollingInterval = null;
5087
- }
5088
- if (this.messageListener) {
5089
- window.removeEventListener('message', this.messageListener);
5090
- this.messageListener = null;
5131
+ cleanupSparkLink() {
5132
+ if (this.sparkLinkWs) {
5133
+ this.sparkLinkWs.onmessage = null;
5134
+ this.sparkLinkWs.onclose = null;
5135
+ this.sparkLinkWs.onerror = null;
5136
+ this.sparkLinkWs.close();
5137
+ this.sparkLinkWs = null;
5091
5138
  }
5092
5139
  }
5093
5140
  /**
5094
5141
  * Handle resending SparkLink.
5095
5142
  */
5096
5143
  async handleSparkLinkResend() {
5097
- const result = await this.sparkLinkHandler.send();
5098
- if (result.success && result.sparkId) {
5099
- this.stopSparkLinkPolling();
5144
+ // Open new WebSocket for the resend
5145
+ const connectionId = await this.openSparkLinkWebSocket();
5146
+ if (!connectionId) {
5147
+ this.cleanupSparkLink();
5148
+ this.setState({
5149
+ view: 'error',
5150
+ message: 'Unable to establish a secure connection. Please try again or choose a different method.',
5151
+ code: 'sparklink_ws_failed',
5152
+ });
5153
+ return;
5154
+ }
5155
+ const result = await this.sparkLinkHandler.send(connectionId);
5156
+ if (result.success) {
5100
5157
  this.setState({
5101
5158
  view: 'sparklink-waiting',
5102
5159
  email: this.recipient,
5103
- sparkId: result.sparkId,
5104
5160
  expiresAt: result.expiresAt,
5105
5161
  });
5106
- this.startSparkLinkPolling(result.sparkId);
5162
+ }
5163
+ else {
5164
+ this.cleanupSparkLink();
5165
+ this.setState({
5166
+ view: 'error',
5167
+ message: result.error ?? 'Failed to resend verification link. Please try again.',
5168
+ code: 'sparklink_resend_failed',
5169
+ });
5107
5170
  }
5108
5171
  }
5109
5172
  /**
5110
5173
  * Handle fallback from SparkLink to TOTP verification code.
5111
5174
  */
5112
5175
  async handleSparkLinkFallback() {
5113
- this.stopSparkLinkPolling();
5176
+ this.cleanupSparkLink();
5114
5177
  this.setState({ view: 'loading' });
5115
5178
  const result = await this.totpHandler.send('email');
5116
5179
  if (!result.success) {