@sparkvault/sdk 1.0.0 → 1.1.6

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.
@@ -726,7 +726,18 @@ class IdentityApi {
726
726
  * Start passkey registration
727
727
  */
728
728
  async startPasskeyRegister(email) {
729
- return this.request('POST', '/passkey/register', { email });
729
+ // Backend returns { options: PublicKeyCredentialCreationOptions, session: {...} }
730
+ // Extract and flatten to match PasskeyChallengeResponse
731
+ const response = await this.request('POST', '/passkey/register', { email });
732
+ return {
733
+ challenge: response.options.challenge,
734
+ rpId: response.options.rp.id,
735
+ rpName: response.options.rp.name,
736
+ userId: response.options.user.id,
737
+ userName: response.options.user.name,
738
+ timeout: response.options.timeout,
739
+ session: response.session,
740
+ };
730
741
  }
731
742
  /**
732
743
  * Complete passkey registration
@@ -734,7 +745,7 @@ class IdentityApi {
734
745
  async completePasskeyRegister(params) {
735
746
  const attestation = params.credential.response;
736
747
  return this.request('POST', '/passkey/register/complete', {
737
- email: params.email,
748
+ session: params.session,
738
749
  credential: {
739
750
  id: params.credential.id,
740
751
  rawId: arrayBufferToBase64url(params.credential.rawId),
@@ -750,7 +761,17 @@ class IdentityApi {
750
761
  * Start passkey verification
751
762
  */
752
763
  async startPasskeyVerify(email) {
753
- return this.request('POST', '/passkey/verify', { email });
764
+ // Backend returns { options: PublicKeyCredentialRequestOptions, session: {...} }
765
+ // Extract and flatten to match PasskeyChallengeResponse
766
+ const response = await this.request('POST', '/passkey/verify', { email });
767
+ return {
768
+ challenge: response.options.challenge,
769
+ rpId: response.options.rpId,
770
+ rpName: 'SparkVault Identity', // Not returned by verify endpoint
771
+ timeout: response.options.timeout,
772
+ allowCredentials: response.options.allowCredentials,
773
+ session: response.session,
774
+ };
754
775
  }
755
776
  /**
756
777
  * Complete passkey verification
@@ -758,7 +779,7 @@ class IdentityApi {
758
779
  async completePasskeyVerify(params) {
759
780
  const assertion = params.credential.response;
760
781
  return this.request('POST', '/passkey/verify/complete', {
761
- email: params.email,
782
+ session: params.session,
762
783
  credential: {
763
784
  id: params.credential.id,
764
785
  rawId: arrayBufferToBase64url(params.credential.rawId),
@@ -795,12 +816,16 @@ class IdentityApi {
795
816
  return `${this.baseUrl}/saml/${provider}?${params}`;
796
817
  }
797
818
  /**
798
- * Send SparkLink email for identity verification
819
+ * Send SparkLink email for identity verification.
820
+ * Includes openerOrigin for postMessage-based completion notification.
799
821
  */
800
822
  async sendSparkLink(email) {
801
823
  return this.request('POST', '/sparklink/send', {
802
824
  email,
803
825
  type: 'verify_identity',
826
+ // Send opener origin for postMessage on verification completion
827
+ // This allows the ceremony page to notify the SDK directly instead of polling
828
+ openerOrigin: typeof window !== 'undefined' ? window.location.origin : undefined,
804
829
  });
805
830
  }
806
831
  /**
@@ -1243,15 +1268,15 @@ class PasskeyHandler {
1243
1268
  */
1244
1269
  async register() {
1245
1270
  try {
1246
- const challenge = await this.api.startPasskeyRegister(this.state.recipient);
1271
+ const challengeResponse = await this.api.startPasskeyRegister(this.state.recipient);
1247
1272
  const publicKeyOptions = {
1248
- challenge: base64urlToArrayBuffer(challenge.challenge),
1273
+ challenge: base64urlToArrayBuffer(challengeResponse.challenge),
1249
1274
  rp: {
1250
- id: challenge.rpId,
1251
- name: challenge.rpName,
1275
+ id: challengeResponse.rpId,
1276
+ name: challengeResponse.rpName,
1252
1277
  },
1253
1278
  user: {
1254
- id: base64urlToArrayBuffer(challenge.userId ?? this.state.recipient),
1279
+ id: base64urlToArrayBuffer(challengeResponse.userId ?? this.state.recipient),
1255
1280
  name: this.state.recipient,
1256
1281
  displayName: this.state.recipient,
1257
1282
  },
@@ -1259,7 +1284,7 @@ class PasskeyHandler {
1259
1284
  { type: 'public-key', alg: -7 }, // ES256
1260
1285
  { type: 'public-key', alg: -257 }, // RS256
1261
1286
  ],
1262
- timeout: challenge.timeout,
1287
+ timeout: challengeResponse.timeout,
1263
1288
  authenticatorSelection: {
1264
1289
  residentKey: 'preferred',
1265
1290
  userVerification: 'preferred',
@@ -1277,7 +1302,7 @@ class PasskeyHandler {
1277
1302
  };
1278
1303
  }
1279
1304
  const response = await this.api.completePasskeyRegister({
1280
- email: this.state.recipient,
1305
+ session: challengeResponse.session,
1281
1306
  credential,
1282
1307
  });
1283
1308
  // Update state - user now has a passkey
@@ -1300,13 +1325,13 @@ class PasskeyHandler {
1300
1325
  */
1301
1326
  async verify() {
1302
1327
  try {
1303
- const challenge = await this.api.startPasskeyVerify(this.state.recipient);
1328
+ const challengeResponse = await this.api.startPasskeyVerify(this.state.recipient);
1304
1329
  const publicKeyOptions = {
1305
- challenge: base64urlToArrayBuffer(challenge.challenge),
1306
- rpId: challenge.rpId,
1307
- timeout: challenge.timeout,
1330
+ challenge: base64urlToArrayBuffer(challengeResponse.challenge),
1331
+ rpId: challengeResponse.rpId,
1332
+ timeout: challengeResponse.timeout,
1308
1333
  userVerification: 'preferred',
1309
- allowCredentials: challenge.allowCredentials?.map((cred) => ({
1334
+ allowCredentials: challengeResponse.allowCredentials?.map((cred) => ({
1310
1335
  id: base64urlToArrayBuffer(cred.id),
1311
1336
  type: cred.type,
1312
1337
  transports: ['internal', 'hybrid', 'usb', 'ble', 'nfc'],
@@ -1323,7 +1348,7 @@ class PasskeyHandler {
1323
1348
  };
1324
1349
  }
1325
1350
  const response = await this.api.completePasskeyVerify({
1326
- email: this.state.recipient,
1351
+ session: challengeResponse.session,
1327
1352
  credential,
1328
1353
  });
1329
1354
  return {
@@ -1731,7 +1756,7 @@ function getStyles(options) {
1731
1756
  ======================================== */
1732
1757
 
1733
1758
  .sv-body {
1734
- padding: 20px;
1759
+ padding: 24px;
1735
1760
  overflow-y: auto;
1736
1761
  flex: 1;
1737
1762
  }
@@ -2464,6 +2489,36 @@ function getStyles(options) {
2464
2489
  color: ${tokens.textSecondary};
2465
2490
  }
2466
2491
 
2492
+ /* SparkLink Expired State */
2493
+ .sv-sparklink-expired {
2494
+ flex-direction: column;
2495
+ gap: 12px;
2496
+ background: ${tokens.bgSubtle};
2497
+ border: 1px solid ${tokens.border};
2498
+ }
2499
+
2500
+ .sv-sparklink-expired-icon {
2501
+ display: flex;
2502
+ justify-content: center;
2503
+ }
2504
+
2505
+ .sv-sparklink-expired-icon svg {
2506
+ width: 48px;
2507
+ height: 48px;
2508
+ }
2509
+
2510
+ .sv-sparklink-expired-text {
2511
+ font-size: 14px;
2512
+ font-weight: 500;
2513
+ color: ${tokens.textSecondary};
2514
+ text-align: center;
2515
+ }
2516
+
2517
+ .sv-expired-icon {
2518
+ width: 48px;
2519
+ height: 48px;
2520
+ }
2521
+
2467
2522
  /* ========================================
2468
2523
  DIVIDER
2469
2524
  ======================================== */
@@ -2511,6 +2566,7 @@ function getStyles(options) {
2511
2566
  .sv-inline-container {
2512
2567
  display: flex;
2513
2568
  flex-direction: column;
2569
+ height: 100%;
2514
2570
  background: ${tokens.bg};
2515
2571
  color: ${tokens.textPrimary};
2516
2572
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
@@ -2526,7 +2582,11 @@ function getStyles(options) {
2526
2582
  }
2527
2583
 
2528
2584
  .sv-inline-body {
2585
+ display: flex;
2586
+ flex-direction: column;
2587
+ justify-content: center;
2529
2588
  min-height: 200px;
2589
+ flex: 1;
2530
2590
  }
2531
2591
 
2532
2592
  /* ========================================
@@ -2545,7 +2605,7 @@ function getStyles(options) {
2545
2605
  }
2546
2606
 
2547
2607
  .sv-body {
2548
- padding: 20px;
2608
+ padding: 24px;
2549
2609
  }
2550
2610
 
2551
2611
  .sv-totp-digit {
@@ -2689,6 +2749,32 @@ function createErrorIcon() {
2689
2749
  svg.appendChild(group);
2690
2750
  return svg;
2691
2751
  }
2752
+ /**
2753
+ * Expired icon - grayscale triangle alert for expired states
2754
+ */
2755
+ function createExpiredIcon() {
2756
+ const svg = createSvgElement('0 0 48 48', 48, 48);
2757
+ svg.classList.add('sv-expired-icon');
2758
+ // Circle background - muted gray
2759
+ svg.appendChild(createCircle(24, 24, 22, {
2760
+ fill: '#F5F5F5',
2761
+ stroke: '#E5E5E5',
2762
+ 'stroke-width': '1',
2763
+ }));
2764
+ // Alert triangle - gray
2765
+ const group = document.createElementNS('http://www.w3.org/2000/svg', 'g');
2766
+ group.setAttribute('transform', 'translate(10, 12)');
2767
+ group.appendChild(createPath('M14 4L2 24h24L14 4z', { fill: '#737373' }));
2768
+ // Exclamation
2769
+ group.appendChild(createPath('M14 10v5', {
2770
+ stroke: '#FFFFFF',
2771
+ 'stroke-width': '2',
2772
+ 'stroke-linecap': 'round',
2773
+ }));
2774
+ group.appendChild(createCircle(14, 19, 1, { fill: '#FFFFFF' }));
2775
+ svg.appendChild(group);
2776
+ return svg;
2777
+ }
2692
2778
  /**
2693
2779
  * Method icons
2694
2780
  */
@@ -4021,6 +4107,8 @@ class SparkLinkWaitingView {
4021
4107
  this.expirationTimer = null;
4022
4108
  this.resendTimer = null;
4023
4109
  this.countdownElement = null;
4110
+ this.countdownSection = null;
4111
+ this.waitingSection = null;
4024
4112
  this.resendButton = null;
4025
4113
  this.backLink = null;
4026
4114
  this.fallbackButton = null;
@@ -4039,7 +4127,7 @@ class SparkLinkWaitingView {
4039
4127
  subtitle.className = 'sv-subtitle';
4040
4128
  subtitle.innerHTML = `We sent a secure link to<br><strong>${escapeHtml(this.props.email)}</strong>`;
4041
4129
  // Spinner with message
4042
- const waitingSection = div('sv-sparklink-waiting');
4130
+ this.waitingSection = div('sv-sparklink-waiting');
4043
4131
  const spinner = document.createElement('div');
4044
4132
  spinner.className = 'sv-spinner sv-spinner-small';
4045
4133
  spinner.setAttribute('role', 'status');
@@ -4047,17 +4135,17 @@ class SparkLinkWaitingView {
4047
4135
  const waitingText = document.createElement('span');
4048
4136
  waitingText.className = 'sv-sparklink-waiting-text';
4049
4137
  waitingText.textContent = 'Waiting for you to click the link...';
4050
- waitingSection.appendChild(spinner);
4051
- waitingSection.appendChild(waitingText);
4138
+ this.waitingSection.appendChild(spinner);
4139
+ this.waitingSection.appendChild(waitingText);
4052
4140
  // Countdown timer
4053
- const countdownSection = div('sv-sparklink-countdown');
4141
+ this.countdownSection = div('sv-sparklink-countdown');
4054
4142
  const countdownLabel = document.createElement('span');
4055
4143
  countdownLabel.textContent = 'Link expires in ';
4056
4144
  this.countdownElement = document.createElement('span');
4057
4145
  this.countdownElement.className = 'sv-sparklink-countdown-time';
4058
4146
  this.startExpirationTimer();
4059
- countdownSection.appendChild(countdownLabel);
4060
- countdownSection.appendChild(this.countdownElement);
4147
+ this.countdownSection.appendChild(countdownLabel);
4148
+ this.countdownSection.appendChild(this.countdownElement);
4061
4149
  // Resend button
4062
4150
  const resendContainer = div('sv-resend-container');
4063
4151
  this.resendButton = document.createElement('button');
@@ -4075,8 +4163,8 @@ class SparkLinkWaitingView {
4075
4163
  container.appendChild(this.backLink);
4076
4164
  container.appendChild(title);
4077
4165
  container.appendChild(subtitle);
4078
- container.appendChild(waitingSection);
4079
- container.appendChild(countdownSection);
4166
+ container.appendChild(this.waitingSection);
4167
+ container.appendChild(this.countdownSection);
4080
4168
  container.appendChild(resendContainer);
4081
4169
  container.appendChild(fallbackContainer);
4082
4170
  return container;
@@ -4118,13 +4206,36 @@ class SparkLinkWaitingView {
4118
4206
  this.countdownElement.textContent = this.formatTime(secondsRemaining);
4119
4207
  },
4120
4208
  onExpired: () => {
4121
- if (!this.countdownElement)
4122
- return;
4123
- this.countdownElement.textContent = 'Expired';
4209
+ this.showExpiredState();
4124
4210
  },
4125
4211
  });
4126
4212
  this.expirationTimer.start();
4127
4213
  }
4214
+ /**
4215
+ * Switch to expired state - static icon, no animation, helpful message
4216
+ */
4217
+ showExpiredState() {
4218
+ // Notify renderer to stop polling
4219
+ this.props.onExpired();
4220
+ // Update waiting section: replace spinner with expired icon
4221
+ if (this.waitingSection) {
4222
+ this.waitingSection.innerHTML = '';
4223
+ this.waitingSection.classList.add('sv-sparklink-expired');
4224
+ // Add expired icon
4225
+ const iconContainer = div('sv-sparklink-expired-icon');
4226
+ iconContainer.appendChild(createExpiredIcon());
4227
+ this.waitingSection.appendChild(iconContainer);
4228
+ // Add expired message
4229
+ const expiredText = document.createElement('span');
4230
+ expiredText.className = 'sv-sparklink-expired-text';
4231
+ expiredText.textContent = 'This link has expired';
4232
+ this.waitingSection.appendChild(expiredText);
4233
+ }
4234
+ // Hide countdown section
4235
+ if (this.countdownSection) {
4236
+ this.countdownSection.style.display = 'none';
4237
+ }
4238
+ }
4128
4239
  handleResend() {
4129
4240
  if (this.resendTimer?.isRunning() || !this.resendButton)
4130
4241
  return;
@@ -4251,8 +4362,9 @@ class IdentityRenderer {
4251
4362
  // View management
4252
4363
  this.currentView = null;
4253
4364
  this.focusTimeoutId = null;
4254
- // SparkLink polling
4365
+ // SparkLink polling and postMessage listener
4255
4366
  this.pollingInterval = null;
4367
+ this.messageListener = null;
4256
4368
  this.container = container;
4257
4369
  this.api = api;
4258
4370
  this.options = options;
@@ -4374,7 +4486,11 @@ class IdentityRenderer {
4374
4486
  this.currentView = view;
4375
4487
  clearChildren(body);
4376
4488
  body.appendChild(view.render());
4377
- const focusable = body.querySelector('input:not([disabled]), button:not([disabled])');
4489
+ // Auto-focus: prefer inputs first, then primary/method buttons (skip back links)
4490
+ const focusable = body.querySelector('input:not([disabled]), ' +
4491
+ 'button.sv-btn-primary:not([disabled]), ' +
4492
+ 'button.sv-btn-email-primary:not([disabled]), ' +
4493
+ 'button.sv-btn-method:not([disabled])');
4378
4494
  if (focusable) {
4379
4495
  // Clear any previous focus timeout to avoid stacking
4380
4496
  if (this.focusTimeoutId !== null) {
@@ -4447,6 +4563,7 @@ class IdentityRenderer {
4447
4563
  onResend: () => this.handleSparkLinkResend(),
4448
4564
  onFallback: () => this.handleSparkLinkFallback(),
4449
4565
  onBack: () => this.showMethodSelect(),
4566
+ onExpired: () => this.stopSparkLinkPolling(),
4450
4567
  });
4451
4568
  case 'oauth-pending':
4452
4569
  return new LoadingView({ message: `Connecting to ${state.provider}...` });
@@ -4789,41 +4906,73 @@ class IdentityRenderer {
4789
4906
  this.callbacks.onSuccess(pendingResult);
4790
4907
  }
4791
4908
  /**
4792
- * Start polling for SparkLink verification status.
4909
+ * Start listening for SparkLink verification completion.
4910
+ * Uses postMessage as primary mechanism (instant notification from ceremony page),
4911
+ * with polling as fallback for edge cases where postMessage might fail.
4793
4912
  */
4794
4913
  startSparkLinkPolling(sparkId) {
4795
4914
  this.stopSparkLinkPolling();
4915
+ // Primary: Listen for postMessage from ceremony page
4916
+ // This is faster and more reliable than polling
4917
+ this.messageListener = (event) => {
4918
+ // Validate message structure and type
4919
+ if (!event.data || typeof event.data !== 'object')
4920
+ return;
4921
+ if (event.data.type !== 'sparklink_verified')
4922
+ return;
4923
+ const { token, identity, identityType } = event.data;
4924
+ // Validate required fields
4925
+ if (!token || !identity)
4926
+ return;
4927
+ this.handleSparkLinkVerified({
4928
+ token,
4929
+ identity,
4930
+ identityType: identityType || 'email',
4931
+ });
4932
+ };
4933
+ window.addEventListener('message', this.messageListener);
4934
+ // Fallback: Poll status endpoint every 2 seconds
4935
+ // This catches cases where postMessage might not work (popup blockers, etc)
4796
4936
  this.pollingInterval = setInterval(async () => {
4797
4937
  const status = await this.sparkLinkHandler.checkStatus(sparkId);
4798
4938
  if (status.verified && status.token && status.identity) {
4799
- this.stopSparkLinkPolling();
4800
- const result = {
4939
+ this.handleSparkLinkVerified({
4801
4940
  token: status.token,
4802
4941
  identity: status.identity,
4803
4942
  identityType: status.identityType || 'email',
4804
- };
4805
- // Check if we should prompt for passkey registration
4806
- if (await this.shouldShowPasskeyPrompt()) {
4807
- this.setState({
4808
- view: 'passkey-prompt',
4809
- email: this.recipient,
4810
- pendingResult: result,
4811
- });
4812
- return;
4813
- }
4814
- this.close();
4815
- this.callbacks.onSuccess(result);
4943
+ });
4816
4944
  }
4817
4945
  }, 2000);
4818
4946
  }
4819
4947
  /**
4820
- * Stop SparkLink polling.
4948
+ * Handle successful SparkLink verification (from either postMessage or polling).
4949
+ */
4950
+ async handleSparkLinkVerified(result) {
4951
+ this.stopSparkLinkPolling();
4952
+ // Check if we should prompt for passkey registration
4953
+ if (await this.shouldShowPasskeyPrompt()) {
4954
+ this.setState({
4955
+ view: 'passkey-prompt',
4956
+ email: this.recipient,
4957
+ pendingResult: result,
4958
+ });
4959
+ return;
4960
+ }
4961
+ this.close();
4962
+ this.callbacks.onSuccess(result);
4963
+ }
4964
+ /**
4965
+ * Stop SparkLink polling and message listener.
4821
4966
  */
4822
4967
  stopSparkLinkPolling() {
4823
4968
  if (this.pollingInterval) {
4824
4969
  clearInterval(this.pollingInterval);
4825
4970
  this.pollingInterval = null;
4826
4971
  }
4972
+ if (this.messageListener) {
4973
+ window.removeEventListener('message', this.messageListener);
4974
+ this.messageListener = null;
4975
+ }
4827
4976
  }
4828
4977
  /**
4829
4978
  * Handle resending SparkLink.