@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.
@@ -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
  /**
@@ -1686,6 +1657,7 @@ function getStyles(options) {
1686
1657
  padding: 14px 20px;
1687
1658
  border-bottom: 1px solid ${tokens.border};
1688
1659
  background: ${tokens.bg};
1660
+ border-radius: 16px 16px 0 0;
1689
1661
  flex-shrink: 0;
1690
1662
  }
1691
1663
 
@@ -4229,7 +4201,7 @@ class SparkLinkWaitingView {
4229
4201
  * Switch to expired state - static icon, no animation, helpful message
4230
4202
  */
4231
4203
  showExpiredState() {
4232
- // Notify renderer to stop polling
4204
+ // Notify renderer to clean up WebSocket connection
4233
4205
  this.props.onExpired();
4234
4206
  // Update waiting section: replace spinner with expired icon
4235
4207
  if (this.waitingSection) {
@@ -4376,9 +4348,8 @@ class IdentityRenderer {
4376
4348
  // View management
4377
4349
  this.currentView = null;
4378
4350
  this.focusTimeoutId = null;
4379
- // SparkLink polling and postMessage listener
4380
- this.pollingInterval = null;
4381
- this.messageListener = null;
4351
+ // SparkLink WebSocket
4352
+ this.sparkLinkWs = null;
4382
4353
  this.container = container;
4383
4354
  this.api = api;
4384
4355
  this.options = options;
@@ -4477,7 +4448,7 @@ class IdentityRenderer {
4477
4448
  clearTimeout(this.focusTimeoutId);
4478
4449
  this.focusTimeoutId = null;
4479
4450
  }
4480
- this.stopSparkLinkPolling();
4451
+ this.cleanupSparkLink();
4481
4452
  this.destroyCurrentView();
4482
4453
  this.container.destroy();
4483
4454
  }
@@ -4582,7 +4553,7 @@ class IdentityRenderer {
4582
4553
  onResend: () => this.handleSparkLinkResend(),
4583
4554
  onFallback: () => this.handleSparkLinkFallback(),
4584
4555
  onBack: () => this.showMethodSelect(),
4585
- onExpired: () => this.stopSparkLinkPolling(),
4556
+ onExpired: () => this.cleanupSparkLink(),
4586
4557
  });
4587
4558
  case 'oauth-pending':
4588
4559
  return new LoadingView({ message: `Connecting to ${state.provider}...` });
@@ -4712,8 +4683,20 @@ class IdentityRenderer {
4712
4683
  }
4713
4684
  case 'sparklink': {
4714
4685
  this.setState({ view: 'loading' });
4715
- 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);
4716
4698
  if (!result.success) {
4699
+ this.cleanupSparkLink();
4717
4700
  this.setState({
4718
4701
  view: 'error',
4719
4702
  message: result.error ?? 'Failed to send SparkLink',
@@ -4724,10 +4707,8 @@ class IdentityRenderer {
4724
4707
  this.setState({
4725
4708
  view: 'sparklink-waiting',
4726
4709
  email: this.recipient,
4727
- sparkId: result.sparkId,
4728
4710
  expiresAt: result.expiresAt,
4729
4711
  });
4730
- this.startSparkLinkPolling(result.sparkId);
4731
4712
  break;
4732
4713
  }
4733
4714
  case 'social': {
@@ -5013,51 +4994,110 @@ class IdentityRenderer {
5013
4994
  this.callbacks.onSuccess(pendingResult);
5014
4995
  }
5015
4996
  /**
5016
- * Start listening for SparkLink verification completion.
5017
- * Uses postMessage as primary mechanism (instant notification from ceremony page),
5018
- * 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.
5019
5000
  */
5020
- startSparkLinkPolling(sparkId) {
5021
- this.stopSparkLinkPolling();
5022
- // Primary: Listen for postMessage from ceremony page
5023
- // This is faster and more reliable than polling
5024
- this.messageListener = (event) => {
5025
- // Validate message structure and type
5026
- if (!event.data || typeof event.data !== 'object')
5027
- return;
5028
- if (event.data.type !== 'sparklink_verified')
5029
- return;
5030
- const { token, identity, identityType, redirect } = event.data;
5031
- // Validate required fields
5032
- 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 {
5033
5062
  return;
5034
- this.handleSparkLinkVerified({
5035
- token,
5036
- identity,
5037
- identityType: identityType || 'email',
5038
- redirect,
5039
- });
5040
- };
5041
- window.addEventListener('message', this.messageListener);
5042
- // Fallback: Poll status endpoint every 2 seconds
5043
- // This catches cases where postMessage might not work (popup blockers, etc)
5044
- this.pollingInterval = setInterval(async () => {
5045
- const status = await this.sparkLinkHandler.checkStatus(sparkId);
5046
- if (status.verified && status.token && status.identity) {
5047
- this.handleSparkLinkVerified({
5048
- token: status.token,
5049
- identity: status.identity,
5050
- identityType: status.identityType || 'email',
5051
- redirect: status.redirect,
5052
- });
5053
5063
  }
5054
- }, 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
+ };
5055
5095
  }
5056
5096
  /**
5057
- * Handle successful SparkLink verification (from either postMessage or polling).
5097
+ * Handle successful SparkLink verification via WebSocket push.
5058
5098
  */
5059
5099
  async handleSparkLinkVerified(result) {
5060
- this.stopSparkLinkPolling();
5100
+ this.cleanupSparkLink();
5061
5101
  // Handle redirect for OIDC/simple mode flows
5062
5102
  if (result.redirect) {
5063
5103
  this.close();
@@ -5077,39 +5117,44 @@ class IdentityRenderer {
5077
5117
  this.callbacks.onSuccess(result);
5078
5118
  }
5079
5119
  /**
5080
- * Stop SparkLink polling and message listener.
5120
+ * Clean up SparkLink WebSocket connection.
5081
5121
  */
5082
- stopSparkLinkPolling() {
5083
- if (this.pollingInterval) {
5084
- clearInterval(this.pollingInterval);
5085
- this.pollingInterval = null;
5086
- }
5087
- if (this.messageListener) {
5088
- window.removeEventListener('message', this.messageListener);
5089
- 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;
5090
5129
  }
5091
5130
  }
5092
5131
  /**
5093
5132
  * Handle resending SparkLink.
5094
5133
  */
5095
5134
  async handleSparkLinkResend() {
5096
- const result = await this.sparkLinkHandler.send();
5097
- if (result.success && result.sparkId) {
5098
- 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) {
5099
5143
  this.setState({
5100
5144
  view: 'sparklink-waiting',
5101
5145
  email: this.recipient,
5102
- sparkId: result.sparkId,
5103
5146
  expiresAt: result.expiresAt,
5104
5147
  });
5105
- this.startSparkLinkPolling(result.sparkId);
5148
+ }
5149
+ else {
5150
+ this.cleanupSparkLink();
5106
5151
  }
5107
5152
  }
5108
5153
  /**
5109
5154
  * Handle fallback from SparkLink to TOTP verification code.
5110
5155
  */
5111
5156
  async handleSparkLinkFallback() {
5112
- this.stopSparkLinkPolling();
5157
+ this.cleanupSparkLink();
5113
5158
  this.setState({ view: 'loading' });
5114
5159
  const result = await this.totpHandler.send('email');
5115
5160
  if (!result.success) {