@sparkvault/sdk 1.11.3 → 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.
@@ -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,110 @@ 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
+ ws.send(JSON.stringify({ action: 'init' }));
5018
+ };
5019
+ ws.onmessage = (event) => {
5020
+ let data;
5021
+ try {
5022
+ data = JSON.parse(event.data);
5023
+ }
5024
+ catch {
5025
+ return;
5026
+ }
5027
+ if (!resolved && data.type === 'connection' && typeof data.connectionId === 'string') {
5028
+ resolved = true;
5029
+ clearTimeout(timeout);
5030
+ this.setupSparkLinkWsHandler(ws);
5031
+ resolve(data.connectionId);
5032
+ return;
5033
+ }
5034
+ };
5035
+ ws.onerror = () => {
5036
+ if (!resolved) {
5037
+ resolved = true;
5038
+ clearTimeout(timeout);
5039
+ resolve(null);
5040
+ }
5041
+ };
5042
+ ws.onclose = () => {
5043
+ if (!resolved) {
5044
+ resolved = true;
5045
+ clearTimeout(timeout);
5046
+ resolve(null);
5047
+ }
5048
+ };
5049
+ });
5050
+ }
5051
+ /**
5052
+ * Set up WebSocket message handler for SparkLink verification results.
5053
+ * Handles: sparklink_verified, sparklink_failed, sparklink_resent.
5054
+ */
5055
+ setupSparkLinkWsHandler(ws) {
5056
+ ws.onmessage = (event) => {
5057
+ let data;
5058
+ try {
5059
+ data = JSON.parse(event.data);
5060
+ }
5061
+ catch {
5034
5062
  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
5063
  }
5055
- }, 2000);
5064
+ switch (data.type) {
5065
+ case 'sparklink_verified':
5066
+ this.handleSparkLinkVerified({
5067
+ token: data.token,
5068
+ identity: data.identity,
5069
+ identityType: data.identityType || 'email',
5070
+ });
5071
+ break;
5072
+ case 'sparklink_failed':
5073
+ this.cleanupSparkLink();
5074
+ this.setState({
5075
+ view: 'error',
5076
+ message: 'Verification failed. Please try again or choose a different method.',
5077
+ code: 'sparklink_failed',
5078
+ });
5079
+ break;
5080
+ case 'sparklink_resent':
5081
+ // Server auto-resent a new link — update countdown timer
5082
+ if (typeof data.expiresAt === 'number') {
5083
+ this.setState({
5084
+ view: 'sparklink-waiting',
5085
+ email: this.recipient,
5086
+ expiresAt: data.expiresAt,
5087
+ });
5088
+ }
5089
+ break;
5090
+ }
5091
+ };
5092
+ ws.onclose = () => {
5093
+ this.sparkLinkWs = null;
5094
+ };
5056
5095
  }
5057
5096
  /**
5058
- * Handle successful SparkLink verification (from either postMessage or polling).
5097
+ * Handle successful SparkLink verification via WebSocket push.
5059
5098
  */
5060
5099
  async handleSparkLinkVerified(result) {
5061
- this.stopSparkLinkPolling();
5100
+ this.cleanupSparkLink();
5062
5101
  // Handle redirect for OIDC/simple mode flows
5063
5102
  if (result.redirect) {
5064
5103
  this.close();
@@ -5078,39 +5117,44 @@ class IdentityRenderer {
5078
5117
  this.callbacks.onSuccess(result);
5079
5118
  }
5080
5119
  /**
5081
- * Stop SparkLink polling and message listener.
5120
+ * Clean up SparkLink WebSocket connection.
5082
5121
  */
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;
5122
+ cleanupSparkLink() {
5123
+ if (this.sparkLinkWs) {
5124
+ this.sparkLinkWs.onmessage = null;
5125
+ this.sparkLinkWs.onclose = null;
5126
+ this.sparkLinkWs.onerror = null;
5127
+ this.sparkLinkWs.close();
5128
+ this.sparkLinkWs = null;
5091
5129
  }
5092
5130
  }
5093
5131
  /**
5094
5132
  * Handle resending SparkLink.
5095
5133
  */
5096
5134
  async handleSparkLinkResend() {
5097
- const result = await this.sparkLinkHandler.send();
5098
- if (result.success && result.sparkId) {
5099
- this.stopSparkLinkPolling();
5135
+ // Open new WebSocket for the resend
5136
+ const connectionId = await this.openSparkLinkWebSocket();
5137
+ if (!connectionId) {
5138
+ this.cleanupSparkLink();
5139
+ return;
5140
+ }
5141
+ const result = await this.sparkLinkHandler.send(connectionId);
5142
+ if (result.success) {
5100
5143
  this.setState({
5101
5144
  view: 'sparklink-waiting',
5102
5145
  email: this.recipient,
5103
- sparkId: result.sparkId,
5104
5146
  expiresAt: result.expiresAt,
5105
5147
  });
5106
- this.startSparkLinkPolling(result.sparkId);
5148
+ }
5149
+ else {
5150
+ this.cleanupSparkLink();
5107
5151
  }
5108
5152
  }
5109
5153
  /**
5110
5154
  * Handle fallback from SparkLink to TOTP verification code.
5111
5155
  */
5112
5156
  async handleSparkLinkFallback() {
5113
- this.stopSparkLinkPolling();
5157
+ this.cleanupSparkLink();
5114
5158
  this.setState({ view: 'loading' });
5115
5159
  const result = await this.totpHandler.send('email');
5116
5160
  if (!result.success) {