@sparkvault/sdk 1.1.6 → 1.8.3

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.
@@ -77,6 +77,7 @@ function resolveConfig(config) {
77
77
  apiBaseUrl: API_URL,
78
78
  identityBaseUrl: IDENTITY_URL,
79
79
  preloadConfig: config.preloadConfig !== false, // Default: true
80
+ backdropBlur: config.backdropBlur !== false, // Default: true
80
81
  };
81
82
  }
82
83
  function validateConfig(config) {
@@ -102,7 +103,7 @@ function validateConfig(config) {
102
103
  *
103
104
  * Per CLAUDE.md §7: Retries allowed only if idempotent, MUST use exponential backoff.
104
105
  */
105
- const DEFAULT_OPTIONS$1 = {
106
+ const DEFAULT_OPTIONS$2 = {
106
107
  maxAttempts: 3,
107
108
  baseDelayMs: 200,
108
109
  maxDelayMs: 5000,
@@ -164,7 +165,7 @@ function sleep(ms) {
164
165
  * );
165
166
  */
166
167
  async function withRetry(fn, options = {}) {
167
- const config = { ...DEFAULT_OPTIONS$1, ...options };
168
+ const config = { ...DEFAULT_OPTIONS$2, ...options };
168
169
  let lastError;
169
170
  for (let attempt = 0; attempt < config.maxAttempts; attempt++) {
170
171
  try {
@@ -496,7 +497,7 @@ class ExpirationTimer {
496
497
  * Base64URL Encoding/Decoding Utilities
497
498
  *
498
499
  * Centralized implementation per CLAUDE.md DRY enforcement.
499
- * Used across identity (WebAuthn), sparks, and RNG modules.
500
+ * Used by Identity (WebAuthn) and Vaults modules.
500
501
  */
501
502
  /**
502
503
  * Encode Uint8Array to base64url string (URL-safe base64 without padding)
@@ -523,35 +524,6 @@ function base64urlEncode(data) {
523
524
  function arrayBufferToBase64url(buffer) {
524
525
  return base64urlEncode(new Uint8Array(buffer));
525
526
  }
526
- /**
527
- * Decode base64url string to Uint8Array
528
- *
529
- * @param base64url - Base64url encoded string
530
- * @returns Decoded bytes as Uint8Array
531
- */
532
- function base64urlDecode(base64url) {
533
- const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/');
534
- const padded = base64 + '='.repeat((4 - (base64.length % 4)) % 4);
535
- const binary = atob(padded);
536
- const bytes = new Uint8Array(binary.length);
537
- for (let i = 0; i < binary.length; i++) {
538
- bytes[i] = binary.charCodeAt(i);
539
- }
540
- return bytes;
541
- }
542
- /**
543
- * Decode base64url string to ArrayBuffer
544
- *
545
- * @param base64url - Base64url encoded string
546
- * @returns Decoded ArrayBuffer
547
- */
548
- function base64urlToArrayBuffer(base64url) {
549
- const bytes = base64urlDecode(base64url);
550
- // Create a new ArrayBuffer with exact copy (avoids TypeScript ArrayBufferLike issue)
551
- const buffer = new ArrayBuffer(bytes.length);
552
- new Uint8Array(buffer).set(bytes);
553
- return buffer;
554
- }
555
527
  /**
556
528
  * Decode base64url string to UTF-8 string
557
529
  *
@@ -563,17 +535,6 @@ function base64urlToString(base64url) {
563
535
  const padded = base64 + '='.repeat((4 - (base64.length % 4)) % 4);
564
536
  return atob(padded);
565
537
  }
566
- /**
567
- * Decode base64url string to number array
568
- * Used by RNG module for bytes format
569
- *
570
- * @param base64url - Base64url encoded string
571
- * @returns Array of byte values
572
- */
573
- function base64urlToBytes(base64url) {
574
- const bytes = base64urlDecode(base64url);
575
- return Array.from(bytes);
576
- }
577
538
 
578
539
  /**
579
540
  * Identity Module Utilities
@@ -620,9 +581,9 @@ function generateState() {
620
581
  * Single responsibility: API calls only.
621
582
  */
622
583
  /** Default request timeout in milliseconds (30 seconds) */
623
- const DEFAULT_TIMEOUT_MS = 30000;
584
+ const DEFAULT_TIMEOUT_MS$1 = 30000;
624
585
  class IdentityApi {
625
- constructor(config, timeoutMs = DEFAULT_TIMEOUT_MS) {
586
+ constructor(config, timeoutMs = DEFAULT_TIMEOUT_MS$1) {
626
587
  /** Cached config promise - allows preloading and deduplication */
627
588
  this.configCache = null;
628
589
  this.config = config;
@@ -707,6 +668,26 @@ class IdentityApi {
707
668
  async checkPasskey(email) {
708
669
  return this.request('POST', '/passkey/check', { email });
709
670
  }
671
+ /**
672
+ * Build auth context params for API requests (OIDC/simple mode).
673
+ * Converts AuthContext to snake_case params expected by backend.
674
+ */
675
+ buildAuthContextParams(authContext) {
676
+ if (!authContext)
677
+ return {};
678
+ const params = {};
679
+ if (authContext.authRequestId) {
680
+ params.auth_request_id = authContext.authRequestId;
681
+ }
682
+ if (authContext.simpleMode) {
683
+ params.simple_mode = {
684
+ success_url: authContext.simpleMode.successUrl,
685
+ failure_url: authContext.simpleMode.failureUrl,
686
+ state: authContext.simpleMode.state,
687
+ };
688
+ }
689
+ return params;
690
+ }
710
691
  /**
711
692
  * Send TOTP code to email or phone
712
693
  */
@@ -714,6 +695,7 @@ class IdentityApi {
714
695
  return this.request('POST', '/totp/send', {
715
696
  recipient: params.recipient,
716
697
  method: params.method,
698
+ ...this.buildAuthContextParams(params.authContext),
717
699
  });
718
700
  }
719
701
  /**
@@ -724,6 +706,7 @@ class IdentityApi {
724
706
  kindling: params.kindling,
725
707
  pin: params.pin,
726
708
  recipient: params.recipient,
709
+ ...this.buildAuthContextParams(params.authContext),
727
710
  });
728
711
  }
729
712
  /**
@@ -764,10 +747,10 @@ class IdentityApi {
764
747
  /**
765
748
  * Start passkey verification
766
749
  */
767
- async startPasskeyVerify(email) {
750
+ async startPasskeyVerify(email, authContext) {
768
751
  // Backend returns { options: PublicKeyCredentialRequestOptions, session: {...} }
769
752
  // Extract and flatten to match PasskeyChallengeResponse
770
- const response = await this.request('POST', '/passkey/verify', { email });
753
+ const response = await this.request('POST', '/passkey/verify', { email, ...this.buildAuthContextParams(authContext) });
771
754
  return {
772
755
  challenge: response.options.challenge,
773
756
  rpId: response.options.rpId,
@@ -784,6 +767,7 @@ class IdentityApi {
784
767
  const assertion = params.credential.response;
785
768
  return this.request('POST', '/passkey/verify/complete', {
786
769
  session: params.session,
770
+ ...this.buildAuthContextParams(params.authContext),
787
771
  credential: {
788
772
  id: params.credential.id,
789
773
  rawId: arrayBufferToBase64url(params.credential.rawId),
@@ -809,33 +793,26 @@ class IdentityApi {
809
793
  });
810
794
  return `${this.baseUrl}/social/${provider}?${params}`;
811
795
  }
812
- /**
813
- * Get SAML redirect URL for enterprise provider
814
- */
815
- getEnterpriseAuthUrl(provider, redirectUri, state) {
816
- const params = new URLSearchParams({
817
- redirect_uri: redirectUri,
818
- state,
819
- });
820
- return `${this.baseUrl}/saml/${provider}?${params}`;
821
- }
822
796
  /**
823
797
  * Send SparkLink email for identity verification.
824
798
  * Includes openerOrigin for postMessage-based completion notification.
825
799
  */
826
- async sendSparkLink(email) {
800
+ async sendSparkLink(email, authContext) {
827
801
  return this.request('POST', '/sparklink/send', {
828
802
  email,
829
803
  type: 'verify_identity',
830
804
  // Send opener origin for postMessage on verification completion
831
805
  // This allows the ceremony page to notify the SDK directly instead of polling
832
806
  openerOrigin: typeof window !== 'undefined' ? window.location.origin : undefined,
807
+ ...this.buildAuthContextParams(authContext),
833
808
  });
834
809
  }
835
810
  /**
836
811
  * Check SparkLink verification status (polling endpoint)
837
812
  */
838
- async checkSparkLinkStatus(sparkId) {
813
+ async checkSparkLinkStatus(sparkId, _authContext) {
814
+ // For GET requests with auth context, we'd need query params, but the status endpoint
815
+ // doesn't need auth context - it's already stored in the sparklink record
839
816
  return this.request('GET', `/sparklink/status/${sparkId}`);
840
817
  }
841
818
  }
@@ -893,10 +870,10 @@ const METHOD_REGISTRY = {
893
870
  },
894
871
  sparklink: {
895
872
  id: 'sparklink',
896
- type: 'magic_link',
873
+ type: 'sparklink',
897
874
  identityType: 'email',
898
875
  name: 'SparkLink',
899
- description: 'Get an instant sign-in magic link via email',
876
+ description: 'Get an instant sign-in link via email',
900
877
  icon: 'link',
901
878
  },
902
879
  // Social providers
@@ -954,98 +931,6 @@ const METHOD_REGISTRY = {
954
931
  icon: 'linkedin',
955
932
  provider: 'linkedin',
956
933
  },
957
- // Enterprise SSO
958
- enterprise_okta: {
959
- id: 'enterprise_okta',
960
- type: 'enterprise',
961
- identityType: 'email',
962
- name: 'Okta',
963
- description: 'Sign in with Okta SSO',
964
- icon: 'okta',
965
- provider: 'okta',
966
- },
967
- enterprise_entra: {
968
- id: 'enterprise_entra',
969
- type: 'enterprise',
970
- identityType: 'email',
971
- name: 'Microsoft Entra',
972
- description: 'Sign in with Microsoft Entra ID',
973
- icon: 'microsoft',
974
- provider: 'entra',
975
- },
976
- enterprise_onelogin: {
977
- id: 'enterprise_onelogin',
978
- type: 'enterprise',
979
- identityType: 'email',
980
- name: 'OneLogin',
981
- description: 'Sign in with OneLogin SSO',
982
- icon: 'onelogin',
983
- provider: 'onelogin',
984
- },
985
- enterprise_ping: {
986
- id: 'enterprise_ping',
987
- type: 'enterprise',
988
- identityType: 'email',
989
- name: 'Ping Identity',
990
- description: 'Sign in with Ping Identity',
991
- icon: 'ping',
992
- provider: 'ping',
993
- },
994
- enterprise_jumpcloud: {
995
- id: 'enterprise_jumpcloud',
996
- type: 'enterprise',
997
- identityType: 'email',
998
- name: 'JumpCloud',
999
- description: 'Sign in with JumpCloud SSO',
1000
- icon: 'jumpcloud',
1001
- provider: 'jumpcloud',
1002
- },
1003
- // HRIS
1004
- hris_bamboohr: {
1005
- id: 'hris_bamboohr',
1006
- type: 'hris',
1007
- identityType: 'email',
1008
- name: 'BambooHR',
1009
- description: 'Sign in with BambooHR',
1010
- icon: 'bamboohr',
1011
- provider: 'bamboohr',
1012
- },
1013
- hris_workday: {
1014
- id: 'hris_workday',
1015
- type: 'hris',
1016
- identityType: 'email',
1017
- name: 'Workday',
1018
- description: 'Sign in with Workday',
1019
- icon: 'workday',
1020
- provider: 'workday',
1021
- },
1022
- hris_adp: {
1023
- id: 'hris_adp',
1024
- type: 'hris',
1025
- identityType: 'email',
1026
- name: 'ADP',
1027
- description: 'Sign in with ADP',
1028
- icon: 'adp',
1029
- provider: 'adp',
1030
- },
1031
- hris_gusto: {
1032
- id: 'hris_gusto',
1033
- type: 'hris',
1034
- identityType: 'email',
1035
- name: 'Gusto',
1036
- description: 'Sign in with Gusto',
1037
- icon: 'gusto',
1038
- provider: 'gusto',
1039
- },
1040
- hris_rippling: {
1041
- id: 'hris_rippling',
1042
- type: 'hris',
1043
- identityType: 'email',
1044
- name: 'Rippling',
1045
- description: 'Sign in with Rippling',
1046
- icon: 'rippling',
1047
- provider: 'rippling',
1048
- },
1049
934
  };
1050
935
  /**
1051
936
  * Enrich method IDs with full metadata
@@ -1079,6 +964,8 @@ class VerificationState {
1079
964
  // Identity being verified
1080
965
  this._recipient = '';
1081
966
  this._identityType = 'email';
967
+ // Auth context for OIDC/simple mode flows
968
+ this._authContext = undefined;
1082
969
  // Passkey state
1083
970
  this._passkey = {
1084
971
  hasPasskey: null,
@@ -1166,6 +1053,13 @@ class VerificationState {
1166
1053
  const allowed = this._sdkConfig?.allowedIdentityTypes ?? ['email'];
1167
1054
  return allowed;
1168
1055
  }
1056
+ // --- Auth Context (OIDC/simple mode) ---
1057
+ get authContext() {
1058
+ return this._authContext;
1059
+ }
1060
+ setAuthContext(context) {
1061
+ this._authContext = context;
1062
+ }
1169
1063
  // --- Passkey State ---
1170
1064
  get passkey() {
1171
1065
  return this._passkey;
@@ -1226,6 +1120,7 @@ class VerificationState {
1226
1120
  this._viewState = { view: 'loading' };
1227
1121
  this._recipient = '';
1228
1122
  this._identityType = 'email';
1123
+ this._authContext = undefined;
1229
1124
  this._passkey.hasPasskey = null;
1230
1125
  this._passkey.isFallbackMode = false;
1231
1126
  this._passkey.pendingResult = null;
@@ -1237,16 +1132,35 @@ class VerificationState {
1237
1132
  /**
1238
1133
  * Passkey Handler
1239
1134
  *
1240
- * Single responsibility: WebAuthn passkey registration and verification.
1241
- * Extracts passkey logic from IdentityRenderer for better separation of concerns.
1135
+ * Handles WebAuthn passkey registration and verification via popup.
1136
+ * Uses a popup window on sparkvault.com domain to ensure WebAuthn works
1137
+ * correctly when the SDK is embedded on third-party client domains.
1138
+ *
1139
+ * Flow:
1140
+ * 1. SDK opens popup to sparkvault.com/passkey/popup
1141
+ * 2. Popup executes WebAuthn (origin = sparkvault.com matches RP ID)
1142
+ * 3. Popup sends result via postMessage
1143
+ * 4. SDK receives result and returns to caller
1242
1144
  */
1145
+ /** Popup window dimensions */
1146
+ const POPUP_WIDTH = 450;
1147
+ const POPUP_HEIGHT = 500;
1148
+ /** Timeout for popup operations (60 seconds) */
1149
+ const POPUP_TIMEOUT_MS = 60000;
1243
1150
  /**
1244
- * Handles WebAuthn passkey registration and verification
1151
+ * Handles WebAuthn passkey registration and verification via popup
1245
1152
  */
1246
1153
  class PasskeyHandler {
1247
1154
  constructor(api, state) {
1248
1155
  this.api = api;
1249
1156
  this.state = state;
1157
+ // Extract base URL and account ID from the API's URL construction
1158
+ // The API uses: `${this.config.identityBaseUrl}/${this.config.accountId}`
1159
+ // We need to access these values for popup URL construction
1160
+ // @ts-expect-error - accessing private config for URL construction
1161
+ const config = api.config;
1162
+ this.baseUrl = config.identityBaseUrl;
1163
+ this.accountId = config.accountId;
1250
1164
  }
1251
1165
  /**
1252
1166
  * Check if user has registered passkeys and validate email domain
@@ -1268,119 +1182,155 @@ class PasskeyHandler {
1268
1182
  }
1269
1183
  }
1270
1184
  /**
1271
- * Register a new passkey for the user
1185
+ * Register a new passkey for the user via popup
1272
1186
  */
1273
1187
  async register() {
1274
- try {
1275
- const challengeResponse = await this.api.startPasskeyRegister(this.state.recipient);
1276
- const publicKeyOptions = {
1277
- challenge: base64urlToArrayBuffer(challengeResponse.challenge),
1278
- rp: {
1279
- id: challengeResponse.rpId,
1280
- name: challengeResponse.rpName,
1281
- },
1282
- user: {
1283
- id: base64urlToArrayBuffer(challengeResponse.userId ?? this.state.recipient),
1284
- name: this.state.recipient,
1285
- displayName: this.state.recipient,
1286
- },
1287
- pubKeyCredParams: [
1288
- { type: 'public-key', alg: -7 }, // ES256
1289
- { type: 'public-key', alg: -257 }, // RS256
1290
- ],
1291
- timeout: challengeResponse.timeout,
1292
- authenticatorSelection: {
1293
- residentKey: 'preferred',
1294
- userVerification: 'preferred',
1295
- },
1296
- attestation: 'none',
1297
- };
1298
- const credential = await navigator.credentials.create({
1299
- publicKey: publicKeyOptions,
1300
- });
1301
- if (!credential) {
1302
- return {
1303
- success: false,
1304
- error: 'Failed to create passkey',
1305
- errorType: 'unknown',
1306
- };
1307
- }
1308
- const response = await this.api.completePasskeyRegister({
1309
- session: challengeResponse.session,
1310
- credential,
1311
- });
1188
+ const result = await this.openPasskeyPopup('register');
1189
+ if (result.success) {
1312
1190
  // Update state - user now has a passkey
1313
1191
  this.state.setPasskeyStatus(true);
1314
- return {
1315
- success: true,
1316
- result: {
1317
- token: response.token,
1318
- identity: response.identity,
1319
- identityType: response.identity_type,
1320
- },
1321
- };
1322
- }
1323
- catch (error) {
1324
- return this.handleError(error);
1325
1192
  }
1193
+ return result;
1326
1194
  }
1327
1195
  /**
1328
- * Verify user with existing passkey
1196
+ * Verify user with existing passkey via popup
1329
1197
  */
1330
1198
  async verify() {
1331
- try {
1332
- const challengeResponse = await this.api.startPasskeyVerify(this.state.recipient);
1333
- const publicKeyOptions = {
1334
- challenge: base64urlToArrayBuffer(challengeResponse.challenge),
1335
- rpId: challengeResponse.rpId,
1336
- timeout: challengeResponse.timeout,
1337
- userVerification: 'preferred',
1338
- allowCredentials: challengeResponse.allowCredentials?.map((cred) => ({
1339
- id: base64urlToArrayBuffer(cred.id),
1340
- type: cred.type,
1341
- transports: ['internal', 'hybrid', 'usb', 'ble', 'nfc'],
1342
- })),
1343
- };
1344
- const credential = await navigator.credentials.get({
1345
- publicKey: publicKeyOptions,
1199
+ return this.openPasskeyPopup('verify');
1200
+ }
1201
+ /**
1202
+ * Open passkey popup and wait for result
1203
+ */
1204
+ async openPasskeyPopup(mode) {
1205
+ return new Promise((resolve) => {
1206
+ // Build popup URL
1207
+ const params = new URLSearchParams({
1208
+ mode,
1209
+ email: this.state.recipient,
1210
+ origin: window.location.origin,
1346
1211
  });
1347
- if (!credential) {
1348
- return {
1212
+ // Add auth context for OIDC/simple mode flows
1213
+ const authContext = this.state.authContext;
1214
+ if (authContext?.authRequestId) {
1215
+ params.set('auth_request_id', authContext.authRequestId);
1216
+ }
1217
+ if (authContext?.simpleMode) {
1218
+ params.set('simple_mode', 'true');
1219
+ params.set('success_url', authContext.simpleMode.successUrl);
1220
+ params.set('failure_url', authContext.simpleMode.failureUrl);
1221
+ if (authContext.simpleMode.state) {
1222
+ params.set('state', authContext.simpleMode.state);
1223
+ }
1224
+ }
1225
+ const popupUrl = `${this.baseUrl}/${this.accountId}/passkey/popup?${params}`;
1226
+ // Calculate popup position (centered)
1227
+ const left = Math.round((window.screen.width - POPUP_WIDTH) / 2);
1228
+ const top = Math.round((window.screen.height - POPUP_HEIGHT) / 2);
1229
+ // Open popup
1230
+ const popup = window.open(popupUrl, 'sparkvault-passkey', `width=${POPUP_WIDTH},height=${POPUP_HEIGHT},left=${left},top=${top},popup=1`);
1231
+ if (!popup) {
1232
+ resolve({
1349
1233
  success: false,
1350
- error: 'Failed to verify passkey',
1351
- errorType: 'unknown',
1352
- };
1234
+ error: 'Popup was blocked. Please allow popups for this site.',
1235
+ errorType: 'popup_blocked',
1236
+ });
1237
+ return;
1353
1238
  }
1354
- const response = await this.api.completePasskeyVerify({
1355
- session: challengeResponse.session,
1356
- credential,
1357
- });
1358
- return {
1359
- success: true,
1360
- result: {
1361
- token: response.token,
1362
- identity: response.identity,
1363
- identityType: response.identity_type,
1364
- },
1239
+ // Set up message listener
1240
+ let resolved = false;
1241
+ const cleanup = () => {
1242
+ window.removeEventListener('message', handleMessage);
1243
+ clearTimeout(timeoutId);
1244
+ clearInterval(pollClosedId);
1245
+ };
1246
+ const handleMessage = (event) => {
1247
+ // Validate origin - must be from sparkvault.com
1248
+ if (!this.isValidMessageOrigin(event.origin)) {
1249
+ return;
1250
+ }
1251
+ const data = event.data;
1252
+ // Verify message type
1253
+ if (data?.type !== 'sparkvault-passkey-result') {
1254
+ return;
1255
+ }
1256
+ resolved = true;
1257
+ cleanup();
1258
+ if (data.success && data.data) {
1259
+ resolve({
1260
+ success: true,
1261
+ result: {
1262
+ token: data.data.token || '',
1263
+ identity: data.data.identity || this.state.recipient,
1264
+ identityType: data.data.identity_type || 'email',
1265
+ redirect: data.data.redirect,
1266
+ },
1267
+ });
1268
+ }
1269
+ else {
1270
+ resolve(this.handlePopupError(data.error, data.message));
1271
+ }
1365
1272
  };
1273
+ window.addEventListener('message', handleMessage);
1274
+ // Timeout after 60 seconds
1275
+ const timeoutId = setTimeout(() => {
1276
+ if (!resolved) {
1277
+ resolved = true;
1278
+ cleanup();
1279
+ popup.close();
1280
+ resolve({
1281
+ success: false,
1282
+ error: 'Passkey operation timed out. Please try again.',
1283
+ errorType: 'unknown',
1284
+ });
1285
+ }
1286
+ }, POPUP_TIMEOUT_MS);
1287
+ // Poll for popup being closed manually
1288
+ const pollClosedId = setInterval(() => {
1289
+ if (popup.closed && !resolved) {
1290
+ resolved = true;
1291
+ cleanup();
1292
+ resolve({
1293
+ success: false,
1294
+ error: 'Authentication was cancelled.',
1295
+ errorType: 'cancelled',
1296
+ });
1297
+ }
1298
+ }, 500);
1299
+ });
1300
+ }
1301
+ /**
1302
+ * Validate that a message origin is from sparkvault.com domain
1303
+ */
1304
+ isValidMessageOrigin(origin) {
1305
+ try {
1306
+ const url = new URL(origin);
1307
+ const hostname = url.hostname;
1308
+ // Allow sparkvault.com and subdomains
1309
+ if (hostname === 'sparkvault.com' || hostname.endsWith('.sparkvault.com')) {
1310
+ return true;
1311
+ }
1312
+ // Allow localhost for development
1313
+ if (hostname === 'localhost') {
1314
+ return true;
1315
+ }
1316
+ return false;
1366
1317
  }
1367
- catch (error) {
1368
- return this.handleError(error);
1318
+ catch {
1319
+ return false;
1369
1320
  }
1370
1321
  }
1371
1322
  /**
1372
- * Handle WebAuthn errors and categorize them
1323
+ * Handle popup error response
1373
1324
  */
1374
- handleError(error) {
1375
- const message = error instanceof Error ? error.message : String(error);
1376
- if (message.includes('not allowed') || message.includes('cancelled')) {
1325
+ handlePopupError(error, message) {
1326
+ if (error === 'cancelled') {
1377
1327
  return {
1378
1328
  success: false,
1379
- error: 'Authentication was cancelled. Please try again.',
1329
+ error: message || 'Authentication was cancelled. Please try again.',
1380
1330
  errorType: 'cancelled',
1381
1331
  };
1382
1332
  }
1383
- if (message.includes('No credentials')) {
1333
+ if (message?.includes('No passkey') || message?.includes('not found')) {
1384
1334
  return {
1385
1335
  success: false,
1386
1336
  error: 'No passkey found. Create one to continue.',
@@ -1389,7 +1339,7 @@ class PasskeyHandler {
1389
1339
  }
1390
1340
  return {
1391
1341
  success: false,
1392
- error: message || 'Passkey authentication failed',
1342
+ error: message || error || 'Passkey authentication failed',
1393
1343
  errorType: 'unknown',
1394
1344
  };
1395
1345
  }
@@ -1417,6 +1367,7 @@ class TotpHandler {
1417
1367
  const response = await this.api.sendTotp({
1418
1368
  recipient: this.state.recipient,
1419
1369
  method,
1370
+ authContext: this.state.authContext,
1420
1371
  });
1421
1372
  // Update state with kindling
1422
1373
  this.state.setKindling(response.kindling);
@@ -1450,6 +1401,7 @@ class TotpHandler {
1450
1401
  const response = await this.api.sendTotp({
1451
1402
  recipient: this.state.recipient,
1452
1403
  method,
1404
+ authContext: this.state.authContext,
1453
1405
  });
1454
1406
  // Update state with new kindling
1455
1407
  this.state.setKindling(response.kindling);
@@ -1483,6 +1435,7 @@ class TotpHandler {
1483
1435
  kindling,
1484
1436
  pin: code,
1485
1437
  recipient: this.state.recipient,
1438
+ authContext: this.state.authContext,
1486
1439
  });
1487
1440
  if (response.verified && response.token) {
1488
1441
  return {
@@ -1491,6 +1444,7 @@ class TotpHandler {
1491
1444
  token: response.token,
1492
1445
  identity: response.identity ?? this.state.recipient,
1493
1446
  identityType: response.identity_type ?? this.state.identityType,
1447
+ redirect: response.redirect,
1494
1448
  },
1495
1449
  };
1496
1450
  }
@@ -1519,11 +1473,11 @@ class TotpHandler {
1519
1473
  /**
1520
1474
  * SparkLink Handler
1521
1475
  *
1522
- * Single responsibility: SparkLink (magic link) sending and status polling.
1476
+ * Single responsibility: SparkLink sending and status polling.
1523
1477
  * Extracts SparkLink logic from IdentityRenderer for better separation of concerns.
1524
1478
  */
1525
1479
  /**
1526
- * Handles SparkLink (magic link) sending and verification polling
1480
+ * Handles SparkLink sending and verification polling
1527
1481
  */
1528
1482
  class SparkLinkHandler {
1529
1483
  constructor(api, state) {
@@ -1535,7 +1489,7 @@ class SparkLinkHandler {
1535
1489
  */
1536
1490
  async send() {
1537
1491
  try {
1538
- const result = await this.api.sendSparkLink(this.state.recipient);
1492
+ const result = await this.api.sendSparkLink(this.state.recipient, this.state.authContext);
1539
1493
  return {
1540
1494
  success: true,
1541
1495
  sparkId: result.sparkId,
@@ -1562,6 +1516,7 @@ class SparkLinkHandler {
1562
1516
  token: result.token,
1563
1517
  identity: result.identity,
1564
1518
  identityType: result.identityType,
1519
+ redirect: result.redirect,
1565
1520
  };
1566
1521
  }
1567
1522
  catch {
@@ -1581,18 +1536,18 @@ class SparkLinkHandler {
1581
1536
  * - Professional typography with clear hierarchy
1582
1537
  * - Refined color palette with single accent
1583
1538
  */
1584
- const STYLE_ID = 'sparkvault-identity-styles';
1539
+ const STYLE_ID$1 = 'sparkvault-identity-styles';
1585
1540
  /**
1586
1541
  * Inject modal styles into the document head.
1587
1542
  */
1588
1543
  function injectStyles(options) {
1589
- if (document.getElementById(STYLE_ID)) {
1544
+ if (document.getElementById(STYLE_ID$1)) {
1590
1545
  updateThemeVariables(options.branding);
1591
1546
  updateBlur(options.backdropBlur);
1592
1547
  return;
1593
1548
  }
1594
1549
  const style = document.createElement('style');
1595
- style.id = STYLE_ID;
1550
+ style.id = STYLE_ID$1;
1596
1551
  style.textContent = getStyles(options);
1597
1552
  document.head.appendChild(style);
1598
1553
  }
@@ -1681,7 +1636,6 @@ function getStyles(options) {
1681
1636
  width: 100%;
1682
1637
  max-width: 400px;
1683
1638
  max-height: calc(100vh - 32px);
1684
- overflow: hidden;
1685
1639
  display: flex;
1686
1640
  flex-direction: column;
1687
1641
  color: ${tokens.textPrimary};
@@ -1760,7 +1714,7 @@ function getStyles(options) {
1760
1714
  ======================================== */
1761
1715
 
1762
1716
  .sv-body {
1763
- padding: 24px;
1717
+ padding: 24px 28px;
1764
1718
  overflow-y: auto;
1765
1719
  flex: 1;
1766
1720
  }
@@ -2570,7 +2524,9 @@ function getStyles(options) {
2570
2524
  .sv-inline-container {
2571
2525
  display: flex;
2572
2526
  flex-direction: column;
2527
+ width: 100%;
2573
2528
  height: 100%;
2529
+ box-sizing: border-box;
2574
2530
  background: ${tokens.bg};
2575
2531
  color: ${tokens.textPrimary};
2576
2532
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
@@ -2578,7 +2534,6 @@ function getStyles(options) {
2578
2534
  -moz-osx-font-smoothing: grayscale;
2579
2535
  border-radius: 12px;
2580
2536
  border: 1px solid ${tokens.border};
2581
- overflow: hidden;
2582
2537
  }
2583
2538
 
2584
2539
  .sv-inline-header {
@@ -2609,7 +2564,7 @@ function getStyles(options) {
2609
2564
  }
2610
2565
 
2611
2566
  .sv-body {
2612
- padding: 24px;
2567
+ padding: 24px 28px;
2613
2568
  }
2614
2569
 
2615
2570
  .sv-totp-digit {
@@ -2709,7 +2664,7 @@ function createCloseIcon() {
2709
2664
  return svg;
2710
2665
  }
2711
2666
  /**
2712
- * Passkey icon
2667
+ * Passkey icon - simple key design
2713
2668
  */
2714
2669
  function createPasskeyIcon() {
2715
2670
  const svg = createSvgElement('0 0 56 56', 56, 56);
@@ -2720,10 +2675,23 @@ function createPasskeyIcon() {
2720
2675
  stroke: '#E0E7FF',
2721
2676
  'stroke-width': '1',
2722
2677
  }));
2723
- // Key icon
2678
+ // Key icon - centered at 28,28
2724
2679
  const group = document.createElementNS('http://www.w3.org/2000/svg', 'g');
2725
- group.setAttribute('transform', 'translate(16, 16)');
2726
- group.appendChild(createPath('M12 8c-2.21 0-4 1.79-4 4 0 1.56.9 2.93 2.21 3.6L10 20v2h4v-2l-.21-4.4C15.1 14.93 16 13.56 16 12c0-2.21-1.79-4-4-4zm0 6c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2z', { fill: '#4F46E5' }));
2680
+ group.setAttribute('transform', 'translate(28, 28)');
2681
+ const strokeAttrs = {
2682
+ stroke: '#4F46E5',
2683
+ 'stroke-width': '2.5',
2684
+ 'stroke-linecap': 'round',
2685
+ 'stroke-linejoin': 'round',
2686
+ fill: 'none',
2687
+ };
2688
+ // Key head (circle)
2689
+ group.appendChild(createCircle(-6, -6, 6, strokeAttrs));
2690
+ // Key shaft
2691
+ group.appendChild(createPath('M-1.5 -1.5L10 10', strokeAttrs));
2692
+ // Key teeth
2693
+ group.appendChild(createPath('M6 6L6 10', strokeAttrs));
2694
+ group.appendChild(createPath('M10 10L10 14', strokeAttrs));
2727
2695
  svg.appendChild(group);
2728
2696
  return svg;
2729
2697
  }
@@ -2930,13 +2898,13 @@ function createUsersIcon() {
2930
2898
  * Creates, shows, hides, and destroys the modal overlay and container.
2931
2899
  * Does NOT handle content rendering - that's the Renderer's job.
2932
2900
  */
2933
- const OVERLAY_ID = 'sparkvault-identity-overlay';
2934
- const MODAL_ID = 'sparkvault-identity-modal';
2901
+ const OVERLAY_ID$1 = 'sparkvault-identity-overlay';
2902
+ const MODAL_ID$1 = 'sparkvault-identity-modal';
2935
2903
  /**
2936
2904
  * Default branding used for immediate modal display before config loads.
2937
2905
  * Uses neutral styling that works with both light and dark themes.
2938
2906
  */
2939
- const DEFAULT_BRANDING = {
2907
+ const DEFAULT_BRANDING$1 = {
2940
2908
  companyName: '',
2941
2909
  logoLight: null,
2942
2910
  logoDark: null,
@@ -2960,7 +2928,7 @@ class ModalContainer {
2960
2928
  */
2961
2929
  createLoading(options, onClose) {
2962
2930
  return this.create({
2963
- branding: DEFAULT_BRANDING,
2931
+ branding: DEFAULT_BRANDING$1,
2964
2932
  backdropBlur: options.backdropBlur,
2965
2933
  }, onClose);
2966
2934
  }
@@ -3011,13 +2979,13 @@ class ModalContainer {
3011
2979
  };
3012
2980
  injectStyles(styleOptions);
3013
2981
  const overlay = document.createElement('div');
3014
- overlay.id = OVERLAY_ID;
2982
+ overlay.id = OVERLAY_ID$1;
3015
2983
  overlay.className = this.backdropBlur ? 'sv-overlay sv-blur' : 'sv-overlay';
3016
2984
  overlay.setAttribute('role', 'dialog');
3017
2985
  overlay.setAttribute('aria-modal', 'true');
3018
2986
  overlay.setAttribute('aria-labelledby', 'sv-modal-title');
3019
2987
  const modal = document.createElement('div');
3020
- modal.id = MODAL_ID;
2988
+ modal.id = MODAL_ID$1;
3021
2989
  modal.className = 'sv-modal';
3022
2990
  const header = this.createHeader(options.branding);
3023
2991
  this.headerElement = header;
@@ -3033,6 +3001,8 @@ class ModalContainer {
3033
3001
  footerLink.target = '_blank';
3034
3002
  footerLink.rel = 'noopener noreferrer';
3035
3003
  footerLink.className = 'sv-footer-link';
3004
+ footerLink.style.color = 'inherit';
3005
+ footerLink.style.textDecoration = 'none';
3036
3006
  footerLink.textContent = 'SparkVault';
3037
3007
  footerText.appendChild(footerLink);
3038
3008
  footer.appendChild(footerText);
@@ -4059,6 +4029,11 @@ class PasskeyPromptView {
4059
4029
  const subtitle = document.createElement('p');
4060
4030
  subtitle.className = 'sv-subtitle';
4061
4031
  subtitle.innerHTML = `Next time, sign in instantly as<br><strong>${escapeHtml(this.props.email)}</strong>`;
4032
+ // Error message if registration failed
4033
+ const errorContainer = div();
4034
+ if (this.props.error) {
4035
+ errorContainer.appendChild(errorMessage(this.props.error));
4036
+ }
4062
4037
  const benefitsContainer = document.createElement('div');
4063
4038
  benefitsContainer.className = 'sv-passkey-prompt-benefits';
4064
4039
  const benefitsList = document.createElement('ul');
@@ -4076,15 +4051,16 @@ class PasskeyPromptView {
4076
4051
  benefitsContainer.appendChild(benefitsList);
4077
4052
  this.addButton = document.createElement('button');
4078
4053
  this.addButton.className = 'sv-btn sv-btn-primary';
4079
- this.addButton.textContent = 'Add Passkey';
4054
+ this.addButton.textContent = this.props.error ? 'Try Again' : 'Add Passkey';
4080
4055
  this.addButton.addEventListener('click', this.boundHandleAdd);
4081
4056
  this.skipLink = document.createElement('button');
4082
4057
  this.skipLink.className = 'sv-skip-link';
4083
- this.skipLink.textContent = 'Not now';
4058
+ this.skipLink.textContent = this.props.error ? 'Skip for now' : 'Not now';
4084
4059
  this.skipLink.addEventListener('click', this.boundHandleSkip);
4085
4060
  container.appendChild(iconContainer);
4086
4061
  container.appendChild(title);
4087
4062
  container.appendChild(subtitle);
4063
+ container.appendChild(errorContainer);
4088
4064
  container.appendChild(benefitsContainer);
4089
4065
  container.appendChild(this.addButton);
4090
4066
  container.appendChild(this.skipLink);
@@ -4153,7 +4129,7 @@ class SparkLinkWaitingView {
4153
4129
  // Resend button
4154
4130
  const resendContainer = div('sv-resend-container');
4155
4131
  this.resendButton = document.createElement('button');
4156
- this.resendButton.className = 'sv-back-link';
4132
+ this.resendButton.className = 'sv-btn sv-btn-secondary';
4157
4133
  this.resendButton.textContent = 'Resend link';
4158
4134
  this.resendButton.addEventListener('click', this.boundHandleResend);
4159
4135
  resendContainer.appendChild(this.resendButton);
@@ -4386,6 +4362,10 @@ class IdentityRenderer {
4386
4362
  else if (options.phone) {
4387
4363
  this.verificationState.setIdentity(options.phone, 'phone');
4388
4364
  }
4365
+ // Set auth context for OIDC/simple mode flows
4366
+ if (options.authContext) {
4367
+ this.verificationState.setAuthContext(options.authContext);
4368
+ }
4389
4369
  }
4390
4370
  // Convenience accessors for state
4391
4371
  get recipient() {
@@ -4449,13 +4429,7 @@ class IdentityRenderer {
4449
4429
  }
4450
4430
  }
4451
4431
  catch (error) {
4452
- const err = this.normalizeError(error);
4453
- // Show error in modal instead of just calling error callback
4454
- this.setState({
4455
- view: 'error',
4456
- message: err.message,
4457
- code: err instanceof SparkVaultError ? err.code : 'config_error',
4458
- });
4432
+ this.handleErrorWithRecovery(error, 'config_error');
4459
4433
  }
4460
4434
  }
4461
4435
  /**
@@ -4548,7 +4522,10 @@ class IdentityRenderer {
4548
4522
  onStart: () => this.handlePasskeyStart(),
4549
4523
  onBack: isFromPrompt
4550
4524
  ? () => this.handlePasskeyRegistrationSkip()
4551
- : () => this.showMethodSelect(),
4525
+ : state.mode === 'verify'
4526
+ // From passkey verify, enable fallback so showMethodSelect doesn't loop back
4527
+ ? () => this.handlePasskeyFallback()
4528
+ : () => this.showMethodSelect(),
4552
4529
  onFallback: passkey.hasPasskey && !passkey.isFallbackMode
4553
4530
  ? () => this.handlePasskeyFallback()
4554
4531
  : undefined,
@@ -4559,6 +4536,7 @@ class IdentityRenderer {
4559
4536
  email: state.email,
4560
4537
  onAddPasskey: () => this.handlePasskeyPromptAdd(state.pendingResult),
4561
4538
  onSkip: () => this.handlePasskeyPromptSkip(state.pendingResult),
4539
+ error: state.error,
4562
4540
  });
4563
4541
  case 'sparklink-waiting':
4564
4542
  return new SparkLinkWaitingView({
@@ -4695,7 +4673,7 @@ class IdentityRenderer {
4695
4673
  });
4696
4674
  break;
4697
4675
  }
4698
- case 'magic_link': {
4676
+ case 'sparklink': {
4699
4677
  this.setState({ view: 'loading' });
4700
4678
  const result = await this.sparkLinkHandler.send();
4701
4679
  if (!result.success) {
@@ -4730,12 +4708,7 @@ class IdentityRenderer {
4730
4708
  }
4731
4709
  }
4732
4710
  catch (error) {
4733
- const err = this.normalizeError(error);
4734
- this.setState({
4735
- view: 'error',
4736
- message: err.message,
4737
- code: err instanceof SparkVaultError ? err.code : 'unknown',
4738
- });
4711
+ this.handleErrorWithRecovery(error, 'unknown');
4739
4712
  }
4740
4713
  }
4741
4714
  async handleTotpSubmit(code) {
@@ -4744,6 +4717,12 @@ class IdentityRenderer {
4744
4717
  const currentMethod = this.verificationState.totp.method ?? 'email';
4745
4718
  const result = await this.totpHandler.verify(code);
4746
4719
  if (result.success && result.result) {
4720
+ // Handle redirect for OIDC/simple mode flows
4721
+ if (result.result.redirect) {
4722
+ this.close();
4723
+ window.location.href = result.result.redirect;
4724
+ return;
4725
+ }
4747
4726
  // Check if we should prompt for passkey registration
4748
4727
  if (await this.shouldShowPasskeyPrompt()) {
4749
4728
  this.setState({
@@ -4757,7 +4736,32 @@ class IdentityRenderer {
4757
4736
  this.callbacks.onSuccess(result.result);
4758
4737
  }
4759
4738
  else {
4760
- // Verification failed
4739
+ // Check if error is expiry - auto-resend if so
4740
+ const errorMsg = result.error?.toLowerCase() ?? '';
4741
+ if (errorMsg.includes('expired')) {
4742
+ // Auto-resend a new code
4743
+ const resendResult = await this.totpHandler.resend();
4744
+ if (resendResult.success) {
4745
+ this.setState({
4746
+ view: 'totp-verify',
4747
+ email: this.recipient,
4748
+ method: currentMethod,
4749
+ kindling: this.verificationState.totp.kindling,
4750
+ error: 'Code expired. We\'ve sent a new one.',
4751
+ });
4752
+ return;
4753
+ }
4754
+ // Resend failed - show error
4755
+ this.setState({
4756
+ view: 'totp-verify',
4757
+ email: this.recipient,
4758
+ method: currentMethod,
4759
+ kindling: this.verificationState.totp.kindling,
4760
+ error: resendResult.error ?? 'Code expired and failed to resend.',
4761
+ });
4762
+ return;
4763
+ }
4764
+ // Other verification failure
4761
4765
  this.setState({
4762
4766
  view: 'totp-verify',
4763
4767
  email: this.recipient,
@@ -4792,6 +4796,12 @@ class IdentityRenderer {
4792
4796
  ? await this.passkeyHandler.register()
4793
4797
  : await this.passkeyHandler.verify();
4794
4798
  if (result.success && result.result) {
4799
+ // Handle redirect for OIDC/simple mode flows
4800
+ if (result.result.redirect) {
4801
+ this.close();
4802
+ window.location.href = result.result.redirect;
4803
+ return;
4804
+ }
4795
4805
  this.close();
4796
4806
  this.callbacks.onSuccess(result.result);
4797
4807
  }
@@ -4831,6 +4841,12 @@ class IdentityRenderer {
4831
4841
  const pendingResult = this.verificationState.passkey.pendingResult;
4832
4842
  if (pendingResult) {
4833
4843
  this.verificationState.setPendingResult(null);
4844
+ // Handle redirect for OIDC/simple mode flows
4845
+ if (pendingResult.redirect) {
4846
+ this.close();
4847
+ window.location.href = pendingResult.redirect;
4848
+ return;
4849
+ }
4834
4850
  this.close();
4835
4851
  this.callbacks.onSuccess(pendingResult);
4836
4852
  }
@@ -4853,6 +4869,38 @@ class IdentityRenderer {
4853
4869
  }
4854
4870
  return new Error(String(error));
4855
4871
  }
4872
+ /**
4873
+ * Check if error is a session/token expiration that should redirect to identity input.
4874
+ */
4875
+ isSessionExpiredError(error) {
4876
+ const message = error.message.toLowerCase();
4877
+ const code = error instanceof SparkVaultError ? error.code : '';
4878
+ return (message.includes('expired') ||
4879
+ message.includes('session') ||
4880
+ message.includes('invalid token') ||
4881
+ message.includes('token invalid') ||
4882
+ code === 'token_expired' ||
4883
+ code === 'session_expired' ||
4884
+ code === 'kindling_expired');
4885
+ }
4886
+ /**
4887
+ * Handle errors - redirect to identity input for session errors, show error view otherwise.
4888
+ */
4889
+ handleErrorWithRecovery(error, fallbackCode) {
4890
+ const err = this.normalizeError(error);
4891
+ if (this.isSessionExpiredError(err)) {
4892
+ // Session expired - let user start over
4893
+ this.showIdentityInput('Your session has expired. Please try again.');
4894
+ }
4895
+ else {
4896
+ // Other error - show error view
4897
+ this.setState({
4898
+ view: 'error',
4899
+ message: err.message,
4900
+ code: err instanceof SparkVaultError ? err.code : fallbackCode,
4901
+ });
4902
+ }
4903
+ }
4856
4904
  /**
4857
4905
  * Check if we should show the passkey registration prompt after PIN verification.
4858
4906
  */
@@ -4882,21 +4930,33 @@ class IdentityRenderer {
4882
4930
  // Directly trigger passkey registration
4883
4931
  const result = await this.passkeyHandler.register();
4884
4932
  if (result.success && result.result) {
4933
+ // Handle redirect for OIDC/simple mode flows
4934
+ if (result.result.redirect) {
4935
+ this.close();
4936
+ window.location.href = result.result.redirect;
4937
+ return;
4938
+ }
4885
4939
  // Passkey created successfully - use the new token
4886
4940
  this.close();
4887
4941
  this.callbacks.onSuccess(result.result);
4888
4942
  }
4889
4943
  else if (result.errorType === 'cancelled') {
4890
- // User cancelled browser dialog - fall back to original result
4891
- this.verificationState.setPendingResult(null);
4892
- this.close();
4893
- this.callbacks.onSuccess(pendingResult);
4944
+ // User cancelled browser dialog - go back to prompt so they can skip or try again
4945
+ this.setState({
4946
+ view: 'passkey-prompt',
4947
+ email: this.recipient,
4948
+ pendingResult,
4949
+ });
4894
4950
  }
4895
4951
  else {
4896
- // Registration failed - fall back to original result
4897
- this.verificationState.setPendingResult(null);
4898
- this.close();
4899
- this.callbacks.onSuccess(pendingResult);
4952
+ // Registration failed - show error with retry option
4953
+ const errorMessage = result.error || 'Failed to create passkey. Please try again.';
4954
+ this.setState({
4955
+ view: 'passkey-prompt',
4956
+ email: this.recipient,
4957
+ pendingResult,
4958
+ error: errorMessage,
4959
+ });
4900
4960
  }
4901
4961
  }
4902
4962
  /**
@@ -4905,6 +4965,12 @@ class IdentityRenderer {
4905
4965
  handlePasskeyPromptSkip(pendingResult) {
4906
4966
  // Set 30-day cookie to suppress future prompts
4907
4967
  this.verificationState.dismissPasskeyPrompt();
4968
+ // Handle redirect for OIDC/simple mode flows
4969
+ if (pendingResult.redirect) {
4970
+ this.close();
4971
+ window.location.href = pendingResult.redirect;
4972
+ return;
4973
+ }
4908
4974
  // Complete the verification
4909
4975
  this.close();
4910
4976
  this.callbacks.onSuccess(pendingResult);
@@ -4924,7 +4990,7 @@ class IdentityRenderer {
4924
4990
  return;
4925
4991
  if (event.data.type !== 'sparklink_verified')
4926
4992
  return;
4927
- const { token, identity, identityType } = event.data;
4993
+ const { token, identity, identityType, redirect } = event.data;
4928
4994
  // Validate required fields
4929
4995
  if (!token || !identity)
4930
4996
  return;
@@ -4932,6 +4998,7 @@ class IdentityRenderer {
4932
4998
  token,
4933
4999
  identity,
4934
5000
  identityType: identityType || 'email',
5001
+ redirect,
4935
5002
  });
4936
5003
  };
4937
5004
  window.addEventListener('message', this.messageListener);
@@ -4944,6 +5011,7 @@ class IdentityRenderer {
4944
5011
  token: status.token,
4945
5012
  identity: status.identity,
4946
5013
  identityType: status.identityType || 'email',
5014
+ redirect: status.redirect,
4947
5015
  });
4948
5016
  }
4949
5017
  }, 2000);
@@ -4953,6 +5021,12 @@ class IdentityRenderer {
4953
5021
  */
4954
5022
  async handleSparkLinkVerified(result) {
4955
5023
  this.stopSparkLinkPolling();
5024
+ // Handle redirect for OIDC/simple mode flows
5025
+ if (result.redirect) {
5026
+ this.close();
5027
+ window.location.href = result.redirect;
5028
+ return;
5029
+ }
4956
5030
  // Check if we should prompt for passkey registration
4957
5031
  if (await this.shouldShowPasskeyPrompt()) {
4958
5032
  this.setState({
@@ -5027,7 +5101,7 @@ class IdentityRenderer {
5027
5101
  *
5028
5102
  * Implements the Container interface for use with IdentityRenderer.
5029
5103
  */
5030
- const DEFAULT_OPTIONS = {
5104
+ const DEFAULT_OPTIONS$1 = {
5031
5105
  showHeader: true,
5032
5106
  showCloseButton: true,
5033
5107
  showFooter: true,
@@ -5043,7 +5117,7 @@ class InlineContainer {
5043
5117
  this.closeBtnClickHandler = null;
5044
5118
  this.effectiveTheme = 'light';
5045
5119
  this.targetElement = targetElement;
5046
- this.containerOptions = { ...DEFAULT_OPTIONS, ...options };
5120
+ this.containerOptions = { ...DEFAULT_OPTIONS$1, ...options };
5047
5121
  }
5048
5122
  /**
5049
5123
  * Create the inline container with loading state.
@@ -5091,6 +5165,8 @@ class InlineContainer {
5091
5165
  footerLink.target = '_blank';
5092
5166
  footerLink.rel = 'noopener noreferrer';
5093
5167
  footerLink.className = 'sv-footer-link';
5168
+ footerLink.style.color = 'inherit';
5169
+ footerLink.style.textDecoration = 'none';
5094
5170
  footerLink.textContent = 'SparkVault';
5095
5171
  footerText.appendChild(footerLink);
5096
5172
  this.footer.appendChild(footerText);
@@ -5200,11 +5276,40 @@ class InlineContainer {
5200
5276
  }
5201
5277
  }
5202
5278
 
5279
+ /**
5280
+ * DOM Utilities
5281
+ *
5282
+ * Shared utilities for DOM operations.
5283
+ */
5284
+ /**
5285
+ * Execute a callback when the DOM is ready.
5286
+ * If DOM is already ready, executes immediately.
5287
+ * Otherwise, waits for DOMContentLoaded.
5288
+ */
5289
+ function onDomReady(callback) {
5290
+ if (document.readyState === 'loading') {
5291
+ document.addEventListener('DOMContentLoaded', callback, { once: true });
5292
+ }
5293
+ else {
5294
+ callback();
5295
+ }
5296
+ }
5297
+
5298
+ /* global Element */
5203
5299
  /**
5204
5300
  * Identity Module
5205
5301
  *
5206
- * Provides identity verification through a DOM-based modal interface.
5207
- * Supports passkey, TOTP, magic link, and social authentication.
5302
+ * Provides identity verification through dialog or inline UI.
5303
+ * Supports passkey, TOTP, SparkLink, and social authentication.
5304
+ *
5305
+ * @example Dialog mode (immediate)
5306
+ * const result = await sv.identity.verify();
5307
+ *
5308
+ * @example Dialog mode (attached to clicks)
5309
+ * sv.identity.attach('.login-btn');
5310
+ *
5311
+ * @example Inline mode
5312
+ * const result = await sv.identity.verify({ target: '#auth-container' });
5208
5313
  */
5209
5314
  function isTokenClaims(payload) {
5210
5315
  if (typeof payload !== 'object' || payload === null) {
@@ -5224,30 +5329,46 @@ function isTokenClaims(payload) {
5224
5329
  class IdentityModule {
5225
5330
  constructor(config) {
5226
5331
  this.renderer = null;
5332
+ this.attachedElements = new Map();
5227
5333
  this.config = config;
5228
5334
  this.api = new IdentityApi(config);
5229
- // Preload config in background for instant modal opening
5335
+ // Preload config in background for instant dialog opening
5230
5336
  if (config.preloadConfig) {
5231
5337
  this.api.preloadConfig();
5232
5338
  }
5233
5339
  }
5234
5340
  /**
5235
- * Open the identity verification modal (popup).
5236
- * Returns when user successfully verifies their identity.
5341
+ * Verify user identity.
5237
5342
  *
5238
- * @example
5239
- * const result = await sv.identity.pop({
5343
+ * - Without `target`: Opens a dialog (modal)
5344
+ * - With `target`: Renders inline into the specified element
5345
+ *
5346
+ * @example Dialog mode
5347
+ * const result = await sv.identity.verify();
5348
+ * console.log(result.token, result.identity);
5349
+ *
5350
+ * @example Inline mode
5351
+ * const result = await sv.identity.verify({
5352
+ * target: '#auth-container',
5240
5353
  * email: 'user@example.com'
5241
5354
  * });
5242
- * console.log(result.token, result.identity, result.identityType);
5243
5355
  */
5244
- async pop(options = {}) {
5356
+ async verify(options = {}) {
5245
5357
  if (this.renderer) {
5246
5358
  this.renderer.close();
5247
5359
  }
5360
+ const isInline = !!options.target;
5361
+ const container = isInline
5362
+ ? this.createInlineContainer(options.target)
5363
+ : new ModalContainer();
5364
+ // Merge global config defaults with per-call options
5365
+ const mergedOptions = {
5366
+ ...options,
5367
+ // Use global backdropBlur unless explicitly overridden per-call
5368
+ backdropBlur: options.backdropBlur ?? this.config.backdropBlur,
5369
+ };
5248
5370
  return new Promise((resolve, reject) => {
5249
- const modal = new ModalContainer();
5250
- this.renderer = new IdentityRenderer(modal, this.api, options, {
5371
+ this.renderer = new IdentityRenderer(container, this.api, mergedOptions, {
5251
5372
  onSuccess: (result) => {
5252
5373
  options.onSuccess?.(result);
5253
5374
  resolve(result);
@@ -5269,58 +5390,81 @@ class IdentityModule {
5269
5390
  });
5270
5391
  }
5271
5392
  /**
5272
- * Render identity verification inline within a target element.
5273
- * Unlike verify() which opens a modal popup, this embeds the UI
5274
- * directly into the specified element.
5393
+ * Attach identity verification to element clicks.
5394
+ * When any matching element is clicked, opens the verification dialog.
5275
5395
  *
5276
- * @example
5277
- * // Render in a div
5278
- * const result = await sv.identity.render({
5279
- * target: document.getElementById('auth-container'),
5280
- * email: 'user@example.com'
5281
- * });
5396
+ * @param selector - CSS selector for elements to attach to
5397
+ * @param options - Verification options and callbacks
5398
+ * @returns Cleanup function to remove event listeners
5282
5399
  *
5283
5400
  * @example
5284
- * // Render in a custom dialog without header/footer
5285
- * const result = await sv.identity.render({
5286
- * target: dialogContentElement,
5287
- * showHeader: false,
5288
- * showFooter: false
5401
+ * const cleanup = sv.identity.attach('.login-btn', {
5402
+ * onSuccess: (result) => console.log('Verified:', result.identity)
5289
5403
  * });
5404
+ *
5405
+ * // Later, remove listeners
5406
+ * cleanup();
5290
5407
  */
5291
- async render(options) {
5292
- if (!options.target || !(options.target instanceof HTMLElement)) {
5293
- throw new ValidationError('target must be a valid HTMLElement');
5294
- }
5295
- if (this.renderer) {
5296
- this.renderer.close();
5297
- }
5298
- return new Promise((resolve, reject) => {
5299
- const container = new InlineContainer(options.target, {
5300
- showHeader: options.showHeader,
5301
- showCloseButton: options.showCloseButton,
5302
- showFooter: options.showFooter,
5303
- });
5304
- this.renderer = new IdentityRenderer(container, this.api, options, {
5305
- onSuccess: (result) => {
5306
- options.onSuccess?.(result);
5307
- resolve(result);
5308
- },
5309
- onError: (error) => {
5310
- options.onError?.(error);
5311
- reject(error);
5312
- },
5313
- onCancel: () => {
5314
- const error = new UserCancelledError();
5315
- options.onCancel?.();
5316
- reject(error);
5317
- },
5408
+ attach(selector, options = {}) {
5409
+ let observer = null;
5410
+ const handleClick = async (event) => {
5411
+ event.preventDefault();
5412
+ try {
5413
+ await this.verify({
5414
+ email: options.email,
5415
+ phone: options.phone,
5416
+ authContext: options.authContext,
5417
+ onSuccess: options.onSuccess,
5418
+ onError: options.onError,
5419
+ onCancel: options.onCancel,
5420
+ });
5421
+ }
5422
+ catch {
5423
+ // Error already handled by callbacks or will use server redirect
5424
+ }
5425
+ };
5426
+ const attachToElements = () => {
5427
+ const elements = document.querySelectorAll(selector);
5428
+ elements.forEach((element) => {
5429
+ element.addEventListener('click', handleClick);
5430
+ this.attachedElements.set(element, () => {
5431
+ element.removeEventListener('click', handleClick);
5432
+ });
5318
5433
  });
5319
- this.renderer.start().catch((error) => {
5320
- options.onError?.(error);
5321
- reject(error);
5434
+ // Watch for dynamically added elements
5435
+ observer = new MutationObserver((mutations) => {
5436
+ mutations.forEach((mutation) => {
5437
+ mutation.addedNodes.forEach((node) => {
5438
+ if (node instanceof Element) {
5439
+ if (node.matches(selector)) {
5440
+ node.addEventListener('click', handleClick);
5441
+ this.attachedElements.set(node, () => {
5442
+ node.removeEventListener('click', handleClick);
5443
+ });
5444
+ }
5445
+ // Check descendants
5446
+ node.querySelectorAll(selector).forEach((el) => {
5447
+ if (!this.attachedElements.has(el)) {
5448
+ el.addEventListener('click', handleClick);
5449
+ this.attachedElements.set(el, () => {
5450
+ el.removeEventListener('click', handleClick);
5451
+ });
5452
+ }
5453
+ });
5454
+ }
5455
+ });
5456
+ });
5322
5457
  });
5323
- });
5458
+ observer.observe(document.body, { childList: true, subtree: true });
5459
+ };
5460
+ // Defer attachment until DOM is ready
5461
+ onDomReady(attachToElements);
5462
+ // Return cleanup function
5463
+ return () => {
5464
+ observer?.disconnect();
5465
+ this.attachedElements.forEach((cleanup) => cleanup());
5466
+ this.attachedElements.clear();
5467
+ };
5324
5468
  }
5325
5469
  /**
5326
5470
  * Verify and decode an identity token.
@@ -5328,10 +5472,6 @@ class IdentityModule {
5328
5472
  *
5329
5473
  * Note: For production use, verify the Ed25519 signature server-side
5330
5474
  * using the JWKS endpoint.
5331
- *
5332
- * @example
5333
- * const claims = await sv.identity.verifyToken(token);
5334
- * console.log(claims.identity, claims.identity_type, claims.method);
5335
5475
  */
5336
5476
  async verifyToken(token) {
5337
5477
  if (!token || typeof token !== 'string') {
@@ -5342,7 +5482,6 @@ class IdentityModule {
5342
5482
  throw new ValidationError('Invalid token format');
5343
5483
  }
5344
5484
  const [, payloadB64] = parts;
5345
- // Use centralized base64url utility (CLAUDE.md DRY)
5346
5485
  const payload = JSON.parse(base64urlToString(payloadB64));
5347
5486
  if (!isTokenClaims(payload)) {
5348
5487
  throw new ValidationError('Invalid token payload structure');
@@ -5358,7 +5497,7 @@ class IdentityModule {
5358
5497
  return payload;
5359
5498
  }
5360
5499
  /**
5361
- * Close the identity modal if open.
5500
+ * Close the identity dialog/inline UI if open.
5362
5501
  */
5363
5502
  close() {
5364
5503
  if (this.renderer) {
@@ -5367,97 +5506,2855 @@ class IdentityModule {
5367
5506
  }
5368
5507
  }
5369
5508
  /**
5370
- * @deprecated Use `pop()` instead. Will be removed in v2.0.
5509
+ * Resolve target to an HTMLElement.
5371
5510
  */
5372
- async verify(options = {}) {
5373
- return this.pop(options);
5374
- }
5375
- }
5376
-
5511
+ createInlineContainer(target) {
5512
+ let element;
5513
+ if (typeof target === 'string') {
5514
+ const found = document.querySelector(target);
5515
+ if (!found || !(found instanceof HTMLElement)) {
5516
+ throw new ValidationError(`Target selector "${target}" did not match any element`);
5517
+ }
5518
+ element = found;
5519
+ }
5520
+ else if (target instanceof HTMLElement) {
5521
+ element = target;
5522
+ }
5523
+ else {
5524
+ throw new ValidationError('Target must be a CSS selector string or HTMLElement');
5525
+ }
5526
+ return new InlineContainer(element);
5527
+ }
5528
+ // Deprecated methods for backwards compatibility
5529
+ /**
5530
+ * @deprecated Use `verify()` instead. Will be removed in v2.0.
5531
+ */
5532
+ async pop(options = {}) {
5533
+ return this.verify(options);
5534
+ }
5535
+ /**
5536
+ * @deprecated Use `verify({ target })` instead. Will be removed in v2.0.
5537
+ */
5538
+ async render(options) {
5539
+ return this.verify(options);
5540
+ }
5541
+ }
5542
+
5543
+ /**
5544
+ * Upload API
5545
+ *
5546
+ * API client for vault upload operations.
5547
+ */
5548
+ /** Default request timeout in milliseconds (30 seconds) */
5549
+ const DEFAULT_TIMEOUT_MS = 30000;
5550
+ /**
5551
+ * Error class for upload API errors.
5552
+ */
5553
+ class UploadApiError extends SparkVaultError {
5554
+ constructor(message, code, httpStatus) {
5555
+ super(message, code, httpStatus);
5556
+ this.httpStatus = httpStatus;
5557
+ this.name = 'UploadApiError';
5558
+ }
5559
+ }
5560
+ /**
5561
+ * Runtime validation for VaultUploadInfoResponse.
5562
+ * Validates required fields exist and have correct types.
5563
+ */
5564
+ function isVaultUploadInfoResponse(data) {
5565
+ if (typeof data !== 'object' || data === null)
5566
+ return false;
5567
+ const d = data;
5568
+ return (typeof d.vault_id === 'string' &&
5569
+ typeof d.vault_name === 'string' &&
5570
+ typeof d.max_size_bytes === 'number' &&
5571
+ typeof d.branding === 'object' &&
5572
+ d.branding !== null &&
5573
+ typeof d.branding.organization_name === 'string' &&
5574
+ typeof d.encryption === 'object' &&
5575
+ d.encryption !== null &&
5576
+ typeof d.encryption.algorithm === 'string' &&
5577
+ (d.forge_status === 'active' || d.forge_status === 'inactive'));
5578
+ }
5579
+ /**
5580
+ * Runtime validation for InitiateUploadResponse.
5581
+ */
5582
+ function isInitiateUploadResponse(data) {
5583
+ if (typeof data !== 'object' || data === null)
5584
+ return false;
5585
+ const d = data;
5586
+ return typeof d.forge_url === 'string' && typeof d.ingot_id === 'string';
5587
+ }
5588
+ class UploadApi {
5589
+ constructor(config) {
5590
+ this.config = config;
5591
+ this.timeoutMs = config.timeout ?? DEFAULT_TIMEOUT_MS;
5592
+ }
5593
+ /**
5594
+ * Get vault upload info (public endpoint).
5595
+ */
5596
+ async getVaultUploadInfo(vaultId) {
5597
+ const url = `${this.config.apiBaseUrl}/v1/files/in/${vaultId}`;
5598
+ const response = await this.request(url, { method: 'GET' });
5599
+ if (!isVaultUploadInfoResponse(response.data)) {
5600
+ throw new UploadApiError('Invalid vault upload info response from server', 'invalid_response', response.status);
5601
+ }
5602
+ const data = response.data;
5603
+ return {
5604
+ vaultId: data.vault_id,
5605
+ vaultName: data.vault_name,
5606
+ maxSizeBytes: data.max_size_bytes,
5607
+ branding: {
5608
+ organizationName: data.branding.organization_name,
5609
+ logoLightUrl: data.branding.logo_light_url,
5610
+ logoDarkUrl: data.branding.logo_dark_url,
5611
+ },
5612
+ encryption: {
5613
+ algorithm: data.encryption.algorithm,
5614
+ keyDerivation: data.encryption.key_derivation,
5615
+ postQuantum: data.encryption.post_quantum,
5616
+ },
5617
+ forgeStatus: data.forge_status,
5618
+ };
5619
+ }
5620
+ /**
5621
+ * Initiate an upload to get Forge URL and ingot ID.
5622
+ */
5623
+ async initiateUpload(vaultId, filename, sizeBytes, contentType) {
5624
+ const url = `${this.config.apiBaseUrl}/v1/files/in/${vaultId}/upload`;
5625
+ const response = await this.request(url, {
5626
+ method: 'POST',
5627
+ body: JSON.stringify({
5628
+ filename,
5629
+ size_bytes: sizeBytes,
5630
+ content_type: contentType,
5631
+ }),
5632
+ });
5633
+ if (!isInitiateUploadResponse(response.data)) {
5634
+ throw new UploadApiError('Invalid initiate upload response from server', 'invalid_response', response.status);
5635
+ }
5636
+ return {
5637
+ forgeUrl: response.data.forge_url,
5638
+ ingotId: response.data.ingot_id,
5639
+ };
5640
+ }
5641
+ /**
5642
+ * Internal request method with timeout handling and error context.
5643
+ */
5644
+ async request(url, options) {
5645
+ const controller = new AbortController();
5646
+ const timeoutId = setTimeout(() => controller.abort(), this.timeoutMs);
5647
+ try {
5648
+ const response = await fetch(url, {
5649
+ method: options.method,
5650
+ headers: {
5651
+ 'Content-Type': 'application/json',
5652
+ 'Accept': 'application/json',
5653
+ },
5654
+ body: options.body,
5655
+ signal: controller.signal,
5656
+ });
5657
+ const json = await response.json();
5658
+ if (!response.ok) {
5659
+ // Safely extract error fields with runtime type checking
5660
+ const errorData = typeof json === 'object' && json !== null ? json : {};
5661
+ const errorObj = typeof errorData.error === 'object' && errorData.error !== null
5662
+ ? errorData.error
5663
+ : errorData;
5664
+ const message = (typeof errorObj.message === 'string' ? errorObj.message : null) ??
5665
+ (typeof errorObj.error === 'string' ? errorObj.error : null) ??
5666
+ 'Request failed';
5667
+ const code = typeof errorObj.code === 'string' ? errorObj.code : 'api_error';
5668
+ throw new UploadApiError(message, code, response.status);
5669
+ }
5670
+ // API responses are wrapped in { data: ..., meta: ... }
5671
+ const data = typeof json === 'object' && json !== null && 'data' in json
5672
+ ? json.data
5673
+ : json;
5674
+ return { data, status: response.status };
5675
+ }
5676
+ catch (error) {
5677
+ // Re-throw SparkVault errors as-is
5678
+ if (error instanceof SparkVaultError) {
5679
+ throw error;
5680
+ }
5681
+ // Convert AbortError to TimeoutError
5682
+ if (error instanceof DOMException && error.name === 'AbortError') {
5683
+ throw new TimeoutError(`Request timed out after ${this.timeoutMs}ms`);
5684
+ }
5685
+ // Network errors with context
5686
+ if (error instanceof TypeError) {
5687
+ throw new NetworkError(`Network request failed: ${error.message}`, { url, method: options.method });
5688
+ }
5689
+ // Unknown errors - preserve message
5690
+ throw new NetworkError(error instanceof Error
5691
+ ? `Request failed: ${error.message}`
5692
+ : 'Request failed: Unknown error');
5693
+ }
5694
+ finally {
5695
+ clearTimeout(timeoutId);
5696
+ }
5697
+ }
5698
+ }
5699
+
5700
+ /**
5701
+ * Upload Widget Styles
5702
+ *
5703
+ * Enterprise-grade styling for the vault upload widget.
5704
+ * Follows the same design principles as the identity modal.
5705
+ */
5706
+ const STYLE_ID = 'sparkvault-upload-styles';
5707
+ /**
5708
+ * Inject upload widget styles into the document head.
5709
+ */
5710
+ function injectUploadStyles(options) {
5711
+ if (document.getElementById(STYLE_ID)) {
5712
+ return;
5713
+ }
5714
+ const style = document.createElement('style');
5715
+ style.id = STYLE_ID;
5716
+ style.textContent = getUploadStyles(options);
5717
+ document.head.appendChild(style);
5718
+ }
5719
+ function getUploadStyles(options) {
5720
+ const { backdropBlur } = options;
5721
+ const enableBlur = backdropBlur !== false;
5722
+ // Design tokens - using light theme for upload widget (matches PublicUploader)
5723
+ const tokens = {
5724
+ // Colors
5725
+ primary: '#4F46E5',
5726
+ primaryHover: '#4338CA',
5727
+ primaryLight: 'rgba(79, 70, 229, 0.08)',
5728
+ error: '#DC2626',
5729
+ errorLight: '#FEF2F2',
5730
+ errorBorder: '#FECACA',
5731
+ success: '#059669',
5732
+ // Backgrounds
5733
+ bg: '#FFFFFF',
5734
+ bgSubtle: '#FAFAFA',
5735
+ bgHover: '#F5F5F5',
5736
+ bgDark: '#0F0F0F',
5737
+ bgDarkSubtle: '#171717',
5738
+ // Borders
5739
+ border: '#E5E5E5',
5740
+ borderHover: '#D4D4D4',
5741
+ borderDark: '#262626',
5742
+ // Text
5743
+ textPrimary: '#0A0A0A',
5744
+ textSecondary: '#525252',
5745
+ textMuted: '#737373',
5746
+ textOnPrimary: '#FFFFFF',
5747
+ textOnDark: '#FAFAFA',
5748
+ textMutedOnDark: '#A3A3A3',
5749
+ // Shadows
5750
+ shadowSm: '0 1px 2px rgba(0, 0, 0, 0.04)',
5751
+ shadowLg: '0 8px 24px rgba(0, 0, 0, 0.12), 0 0 0 1px rgba(0, 0, 0, 0.04)',
5752
+ };
5753
+ return `
5754
+ /* ========================================
5755
+ OVERLAY & MODAL CONTAINER
5756
+ ======================================== */
5757
+
5758
+ .svu-overlay {
5759
+ position: fixed;
5760
+ inset: 0;
5761
+ background: rgba(0, 0, 0, 0.5);
5762
+ display: flex;
5763
+ align-items: center;
5764
+ justify-content: center;
5765
+ z-index: 999999;
5766
+ padding: 16px;
5767
+ box-sizing: border-box;
5768
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
5769
+ -webkit-font-smoothing: antialiased;
5770
+ -moz-osx-font-smoothing: grayscale;
5771
+ }
5772
+
5773
+ .svu-overlay.svu-blur {
5774
+ backdrop-filter: blur(${enableBlur ? '8px' : '0'});
5775
+ -webkit-backdrop-filter: blur(${enableBlur ? '8px' : '0'});
5776
+ }
5777
+
5778
+ .svu-modal {
5779
+ background: ${tokens.bg};
5780
+ border-radius: 16px;
5781
+ box-shadow: ${tokens.shadowLg};
5782
+ width: 100%;
5783
+ max-width: 480px;
5784
+ max-height: calc(100vh - 32px);
5785
+ display: flex;
5786
+ flex-direction: column;
5787
+ color: ${tokens.textPrimary};
5788
+ overflow: hidden;
5789
+ }
5790
+
5791
+ .svu-modal.svu-with-sidebar {
5792
+ max-width: 900px;
5793
+ flex-direction: row;
5794
+ }
5795
+
5796
+ .svu-modal-main {
5797
+ flex: 1;
5798
+ display: flex;
5799
+ flex-direction: column;
5800
+ min-width: 0;
5801
+ }
5802
+
5803
+ /* Dark card variant for uploading/ceremony states */
5804
+ .svu-modal.svu-dark {
5805
+ background: ${tokens.bgDark};
5806
+ color: ${tokens.textOnDark};
5807
+ }
5808
+
5809
+ /* ========================================
5810
+ HEADER
5811
+ ======================================== */
5812
+
5813
+ .svu-header {
5814
+ display: flex;
5815
+ align-items: center;
5816
+ justify-content: space-between;
5817
+ padding: 14px 20px;
5818
+ border-bottom: 1px solid ${tokens.border};
5819
+ background: ${tokens.bg};
5820
+ flex-shrink: 0;
5821
+ }
5822
+
5823
+ .svu-dark .svu-header {
5824
+ border-bottom-color: ${tokens.borderDark};
5825
+ background: ${tokens.bgDark};
5826
+ }
5827
+
5828
+ .svu-header-title {
5829
+ display: flex;
5830
+ align-items: center;
5831
+ gap: 12px;
5832
+ min-width: 0;
5833
+ }
5834
+
5835
+ .svu-logo {
5836
+ height: 28px;
5837
+ width: auto;
5838
+ max-width: 140px;
5839
+ object-fit: contain;
5840
+ flex-shrink: 0;
5841
+ }
5842
+
5843
+ .svu-company-name {
5844
+ font-size: 15px;
5845
+ font-weight: 600;
5846
+ letter-spacing: -0.01em;
5847
+ margin: 0;
5848
+ white-space: nowrap;
5849
+ overflow: hidden;
5850
+ text-overflow: ellipsis;
5851
+ }
5852
+
5853
+ .svu-close-btn {
5854
+ flex-shrink: 0;
5855
+ width: 32px;
5856
+ height: 32px;
5857
+ display: flex;
5858
+ align-items: center;
5859
+ justify-content: center;
5860
+ background: transparent;
5861
+ border: none;
5862
+ border-radius: 8px;
5863
+ cursor: pointer;
5864
+ color: ${tokens.textMuted};
5865
+ transition: background 0.15s ease, color 0.15s ease;
5866
+ }
5867
+
5868
+ .svu-close-btn:hover {
5869
+ background: ${tokens.bgHover};
5870
+ color: ${tokens.textPrimary};
5871
+ }
5872
+
5873
+ .svu-dark .svu-close-btn {
5874
+ color: ${tokens.textMutedOnDark};
5875
+ }
5876
+
5877
+ .svu-dark .svu-close-btn:hover {
5878
+ background: ${tokens.bgDarkSubtle};
5879
+ color: ${tokens.textOnDark};
5880
+ }
5881
+
5882
+ /* ========================================
5883
+ BODY
5884
+ ======================================== */
5885
+
5886
+ .svu-body {
5887
+ padding: 24px 28px;
5888
+ overflow-y: auto;
5889
+ flex: 1;
5890
+ }
5891
+
5892
+ /* ========================================
5893
+ FOOTER
5894
+ ======================================== */
5895
+
5896
+ .svu-footer {
5897
+ padding: 10px 20px;
5898
+ border-top: 1px solid ${tokens.border};
5899
+ text-align: center;
5900
+ background: ${tokens.bgSubtle};
5901
+ flex-shrink: 0;
5902
+ }
5903
+
5904
+ .svu-dark .svu-footer {
5905
+ border-top-color: ${tokens.borderDark};
5906
+ background: ${tokens.bgDarkSubtle};
5907
+ }
5908
+
5909
+ .svu-footer-text {
5910
+ font-size: 10px;
5911
+ color: ${tokens.textMuted};
5912
+ letter-spacing: 0.02em;
5913
+ }
5914
+
5915
+ .svu-dark .svu-footer-text {
5916
+ color: ${tokens.textMutedOnDark};
5917
+ }
5918
+
5919
+ .svu-footer-link {
5920
+ color: ${tokens.textSecondary};
5921
+ text-decoration: none;
5922
+ font-weight: 500;
5923
+ transition: color 0.15s ease;
5924
+ }
5925
+
5926
+ .svu-footer-link:hover {
5927
+ color: ${tokens.primary};
5928
+ }
5929
+
5930
+ /* ========================================
5931
+ LOADING STATE
5932
+ ======================================== */
5933
+
5934
+ .svu-loading {
5935
+ display: flex;
5936
+ flex-direction: column;
5937
+ align-items: center;
5938
+ justify-content: center;
5939
+ padding: 48px 24px;
5940
+ gap: 16px;
5941
+ }
5942
+
5943
+ .svu-spinner {
5944
+ width: 28px;
5945
+ height: 28px;
5946
+ border: 2.5px solid ${tokens.border};
5947
+ border-top-color: ${tokens.primary};
5948
+ border-radius: 50%;
5949
+ animation: svu-spin 0.7s linear infinite;
5950
+ }
5951
+
5952
+ .svu-loading-text {
5953
+ font-size: 14px;
5954
+ color: ${tokens.textSecondary};
5955
+ margin: 0;
5956
+ }
5957
+
5958
+ @keyframes svu-spin {
5959
+ to { transform: rotate(360deg); }
5960
+ }
5961
+
5962
+ /* ========================================
5963
+ FORM VIEW
5964
+ ======================================== */
5965
+
5966
+ .svu-form-view {
5967
+ display: flex;
5968
+ flex-direction: column;
5969
+ align-items: center;
5970
+ }
5971
+
5972
+ .svu-title {
5973
+ font-size: 18px;
5974
+ font-weight: 600;
5975
+ letter-spacing: -0.02em;
5976
+ line-height: 1.3;
5977
+ margin: 0 0 6px 0;
5978
+ text-align: center;
5979
+ color: ${tokens.textPrimary};
5980
+ }
5981
+
5982
+ .svu-dark .svu-title {
5983
+ color: ${tokens.textOnDark};
5984
+ }
5985
+
5986
+ .svu-subtitle {
5987
+ font-size: 14px;
5988
+ font-weight: 400;
5989
+ line-height: 1.5;
5990
+ color: ${tokens.textSecondary};
5991
+ margin: 0 0 20px 0;
5992
+ text-align: center;
5993
+ }
5994
+
5995
+ .svu-subtitle strong {
5996
+ color: ${tokens.textPrimary};
5997
+ font-weight: 500;
5998
+ }
5999
+
6000
+ /* Drop Zone */
6001
+ .svu-drop-zone {
6002
+ width: 100%;
6003
+ border: 2px dashed ${tokens.border};
6004
+ border-radius: 12px;
6005
+ padding: 32px 24px;
6006
+ text-align: center;
6007
+ cursor: pointer;
6008
+ transition: border-color 0.15s ease, background 0.15s ease;
6009
+ margin-bottom: 16px;
6010
+ }
6011
+
6012
+ .svu-drop-zone:hover {
6013
+ border-color: ${tokens.borderHover};
6014
+ background: ${tokens.bgSubtle};
6015
+ }
6016
+
6017
+ .svu-drop-zone.svu-dragging {
6018
+ border-color: ${tokens.primary};
6019
+ background: ${tokens.primaryLight};
6020
+ }
6021
+
6022
+ .svu-drop-zone.svu-has-file {
6023
+ border-style: solid;
6024
+ border-color: ${tokens.primary};
6025
+ background: ${tokens.primaryLight};
6026
+ cursor: default;
6027
+ }
6028
+
6029
+ .svu-drop-icon {
6030
+ color: ${tokens.textMuted};
6031
+ margin-bottom: 12px;
6032
+ }
6033
+
6034
+ .svu-drop-text {
6035
+ font-size: 14px;
6036
+ font-weight: 500;
6037
+ color: ${tokens.textPrimary};
6038
+ margin: 0 0 4px 0;
6039
+ }
6040
+
6041
+ .svu-drop-subtext {
6042
+ font-size: 13px;
6043
+ color: ${tokens.textSecondary};
6044
+ margin: 0 0 8px 0;
6045
+ }
6046
+
6047
+ .svu-drop-hint {
6048
+ font-size: 12px;
6049
+ color: ${tokens.textMuted};
6050
+ margin: 0;
6051
+ }
6052
+
6053
+ /* Selected File */
6054
+ .svu-selected-file {
6055
+ display: flex;
6056
+ align-items: center;
6057
+ gap: 12px;
6058
+ width: 100%;
6059
+ }
6060
+
6061
+ .svu-file-icon {
6062
+ color: ${tokens.primary};
6063
+ flex-shrink: 0;
6064
+ }
6065
+
6066
+ .svu-file-info {
6067
+ flex: 1;
6068
+ min-width: 0;
6069
+ text-align: left;
6070
+ }
6071
+
6072
+ .svu-file-name {
6073
+ display: block;
6074
+ font-size: 14px;
6075
+ font-weight: 500;
6076
+ color: ${tokens.textPrimary};
6077
+ white-space: nowrap;
6078
+ overflow: hidden;
6079
+ text-overflow: ellipsis;
6080
+ }
6081
+
6082
+ .svu-file-size {
6083
+ display: block;
6084
+ font-size: 12px;
6085
+ color: ${tokens.textSecondary};
6086
+ }
6087
+
6088
+ .svu-remove-file {
6089
+ flex-shrink: 0;
6090
+ width: 24px;
6091
+ height: 24px;
6092
+ display: flex;
6093
+ align-items: center;
6094
+ justify-content: center;
6095
+ background: transparent;
6096
+ border: none;
6097
+ border-radius: 50%;
6098
+ cursor: pointer;
6099
+ font-size: 18px;
6100
+ color: ${tokens.textMuted};
6101
+ transition: background 0.15s ease, color 0.15s ease;
6102
+ }
6103
+
6104
+ .svu-remove-file:hover {
6105
+ background: ${tokens.bgHover};
6106
+ color: ${tokens.error};
6107
+ }
6108
+
6109
+ /* Max Size */
6110
+ .svu-max-size {
6111
+ font-size: 12px;
6112
+ color: ${tokens.textMuted};
6113
+ margin: 0 0 16px 0;
6114
+ }
6115
+
6116
+ /* Upload Button */
6117
+ .svu-btn {
6118
+ display: inline-flex;
6119
+ align-items: center;
6120
+ justify-content: center;
6121
+ gap: 8px;
6122
+ width: 100%;
6123
+ padding: 12px 20px;
6124
+ font-size: 14px;
6125
+ font-weight: 500;
6126
+ letter-spacing: -0.005em;
6127
+ line-height: 1.4;
6128
+ border: none;
6129
+ border-radius: 8px;
6130
+ cursor: pointer;
6131
+ transition: background 0.15s ease, transform 0.1s ease;
6132
+ box-sizing: border-box;
6133
+ }
6134
+
6135
+ .svu-btn:disabled {
6136
+ opacity: 0.5;
6137
+ cursor: not-allowed;
6138
+ }
6139
+
6140
+ .svu-btn:active:not(:disabled) {
6141
+ transform: scale(0.98);
6142
+ }
6143
+
6144
+ .svu-btn-primary {
6145
+ background: ${tokens.primary};
6146
+ color: ${tokens.textOnPrimary};
6147
+ box-shadow: ${tokens.shadowSm};
6148
+ }
6149
+
6150
+ .svu-btn-primary:hover:not(:disabled) {
6151
+ background: ${tokens.primaryHover};
6152
+ }
6153
+
6154
+ .svu-btn-secondary {
6155
+ background: ${tokens.bgSubtle};
6156
+ color: ${tokens.textPrimary};
6157
+ border: 1px solid ${tokens.border};
6158
+ }
6159
+
6160
+ .svu-btn-secondary:hover:not(:disabled) {
6161
+ background: ${tokens.bgHover};
6162
+ }
6163
+
6164
+ /* Error Alert */
6165
+ .svu-error-alert {
6166
+ background: ${tokens.errorLight};
6167
+ border: 1px solid ${tokens.errorBorder};
6168
+ color: ${tokens.error};
6169
+ padding: 10px 12px;
6170
+ border-radius: 8px;
6171
+ font-size: 13px;
6172
+ font-weight: 450;
6173
+ margin-bottom: 16px;
6174
+ width: 100%;
6175
+ }
6176
+
6177
+ /* Metadata Grid */
6178
+ .svu-metadata {
6179
+ width: 100%;
6180
+ margin-top: 20px;
6181
+ padding-top: 20px;
6182
+ border-top: 1px solid ${tokens.border};
6183
+ }
6184
+
6185
+ .svu-metadata h4 {
6186
+ font-size: 12px;
6187
+ font-weight: 600;
6188
+ color: ${tokens.textSecondary};
6189
+ text-transform: uppercase;
6190
+ letter-spacing: 0.05em;
6191
+ margin: 0 0 12px 0;
6192
+ }
6193
+
6194
+ .svu-metadata-grid {
6195
+ display: grid;
6196
+ grid-template-columns: 1fr 1fr;
6197
+ gap: 8px;
6198
+ }
6199
+
6200
+ .svu-metadata-item {
6201
+ font-size: 12px;
6202
+ }
6203
+
6204
+ .svu-metadata-item.svu-full-width {
6205
+ grid-column: span 2;
6206
+ }
6207
+
6208
+ .svu-metadata-label {
6209
+ display: block;
6210
+ color: ${tokens.textMuted};
6211
+ margin-bottom: 2px;
6212
+ }
6213
+
6214
+ .svu-metadata-value {
6215
+ display: block;
6216
+ color: ${tokens.textPrimary};
6217
+ font-weight: 500;
6218
+ }
6219
+
6220
+ .svu-metadata-value.svu-status-active {
6221
+ color: ${tokens.success};
6222
+ }
6223
+
6224
+ /* ========================================
6225
+ UPLOADING VIEW
6226
+ ======================================== */
6227
+
6228
+ .svu-uploading-view {
6229
+ display: flex;
6230
+ flex-direction: column;
6231
+ align-items: center;
6232
+ }
6233
+
6234
+ .svu-stages {
6235
+ display: flex;
6236
+ align-items: center;
6237
+ justify-content: center;
6238
+ gap: 12px;
6239
+ margin: 24px 0;
6240
+ width: 100%;
6241
+ }
6242
+
6243
+ .svu-stage {
6244
+ display: flex;
6245
+ flex-direction: column;
6246
+ align-items: center;
6247
+ gap: 8px;
6248
+ opacity: 0.5;
6249
+ transition: opacity 0.3s ease;
6250
+ }
6251
+
6252
+ .svu-stage.svu-active {
6253
+ opacity: 1;
6254
+ }
6255
+
6256
+ .svu-stage span {
6257
+ font-size: 11px;
6258
+ color: ${tokens.textMutedOnDark};
6259
+ }
6260
+
6261
+ .svu-stage-sub {
6262
+ font-size: 10px !important;
6263
+ color: ${tokens.primary} !important;
6264
+ }
6265
+
6266
+ .svu-stage-arrow {
6267
+ width: 24px;
6268
+ height: 2px;
6269
+ background: ${tokens.borderDark};
6270
+ position: relative;
6271
+ }
6272
+
6273
+ .svu-stage-arrow.svu-active::after {
6274
+ content: '';
6275
+ position: absolute;
6276
+ left: 0;
6277
+ top: 0;
6278
+ height: 100%;
6279
+ background: ${tokens.primary};
6280
+ animation: svu-arrow-flow 1s ease-in-out infinite;
6281
+ }
6282
+
6283
+ @keyframes svu-arrow-flow {
6284
+ 0% { width: 0; }
6285
+ 50% { width: 100%; }
6286
+ 100% { width: 100%; }
6287
+ }
6288
+
6289
+ /* Progress Bar */
6290
+ .svu-progress {
6291
+ width: 100%;
6292
+ margin: 20px 0;
6293
+ }
6294
+
6295
+ .svu-progress-bar {
6296
+ width: 100%;
6297
+ height: 6px;
6298
+ background: ${tokens.borderDark};
6299
+ border-radius: 3px;
6300
+ overflow: hidden;
6301
+ }
6302
+
6303
+ .svu-progress-fill {
6304
+ height: 100%;
6305
+ background: ${tokens.primary};
6306
+ border-radius: 3px;
6307
+ transition: width 0.3s ease;
6308
+ }
6309
+
6310
+ .svu-progress-text {
6311
+ display: flex;
6312
+ justify-content: space-between;
6313
+ margin-top: 8px;
6314
+ font-size: 12px;
6315
+ color: ${tokens.textMutedOnDark};
6316
+ }
6317
+
6318
+ /* Forging Info */
6319
+ .svu-forging-info {
6320
+ width: 100%;
6321
+ margin-top: 20px;
6322
+ padding: 16px;
6323
+ background: ${tokens.bgDarkSubtle};
6324
+ border-radius: 8px;
6325
+ }
6326
+
6327
+ .svu-forging-line {
6328
+ font-size: 12px;
6329
+ color: ${tokens.textMutedOnDark};
6330
+ margin: 0 0 8px 0;
6331
+ }
6332
+
6333
+ .svu-forging-line:last-child {
6334
+ margin-bottom: 0;
6335
+ }
6336
+
6337
+ .svu-mono {
6338
+ font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Monaco, monospace;
6339
+ color: ${tokens.textOnDark};
6340
+ }
6341
+
6342
+ /* ========================================
6343
+ CEREMONY VIEW
6344
+ ======================================== */
6345
+
6346
+ .svu-ceremony-view {
6347
+ display: flex;
6348
+ flex-direction: column;
6349
+ align-items: center;
6350
+ }
6351
+
6352
+ .svu-ceremony-steps {
6353
+ width: 100%;
6354
+ margin: 20px 0;
6355
+ }
6356
+
6357
+ .svu-ceremony-step {
6358
+ display: flex;
6359
+ align-items: center;
6360
+ gap: 12px;
6361
+ padding: 10px 0;
6362
+ opacity: 0.4;
6363
+ transition: opacity 0.3s ease;
6364
+ }
6365
+
6366
+ .svu-ceremony-step.svu-active {
6367
+ opacity: 1;
6368
+ }
6369
+
6370
+ .svu-ceremony-step.svu-complete {
6371
+ opacity: 0.7;
6372
+ }
6373
+
6374
+ .svu-step-icon {
6375
+ width: 20px;
6376
+ height: 20px;
6377
+ display: flex;
6378
+ align-items: center;
6379
+ justify-content: center;
6380
+ color: ${tokens.primary};
6381
+ }
6382
+
6383
+ .svu-step-text {
6384
+ font-size: 13px;
6385
+ color: ${tokens.textOnDark};
6386
+ }
6387
+
6388
+ /* Spinning loader */
6389
+ .svu-step-icon .svu-spin {
6390
+ animation: svu-spin 0.7s linear infinite;
6391
+ }
6392
+
6393
+ /* ========================================
6394
+ COMPLETE VIEW
6395
+ ======================================== */
6396
+
6397
+ .svu-complete-view {
6398
+ display: flex;
6399
+ flex-direction: column;
6400
+ align-items: center;
6401
+ animation: svu-scale-in 0.3s ease;
6402
+ }
6403
+
6404
+ @keyframes svu-scale-in {
6405
+ from {
6406
+ opacity: 0;
6407
+ transform: scale(0.95);
6408
+ }
6409
+ to {
6410
+ opacity: 1;
6411
+ transform: scale(1);
6412
+ }
6413
+ }
6414
+
6415
+ .svu-success-icon {
6416
+ color: ${tokens.success};
6417
+ margin-bottom: 16px;
6418
+ }
6419
+
6420
+ .svu-success-title {
6421
+ font-size: 20px;
6422
+ font-weight: 600;
6423
+ color: ${tokens.textPrimary};
6424
+ margin: 0 0 24px 0;
6425
+ text-align: center;
6426
+ }
6427
+
6428
+ .svu-success-details {
6429
+ width: 100%;
6430
+ background: ${tokens.bgSubtle};
6431
+ border-radius: 8px;
6432
+ padding: 16px;
6433
+ margin-bottom: 16px;
6434
+ }
6435
+
6436
+ .svu-success-row {
6437
+ display: flex;
6438
+ justify-content: space-between;
6439
+ align-items: center;
6440
+ padding: 8px 0;
6441
+ font-size: 13px;
6442
+ border-bottom: 1px solid ${tokens.border};
6443
+ }
6444
+
6445
+ .svu-success-row:last-child {
6446
+ border-bottom: none;
6447
+ }
6448
+
6449
+ .svu-success-label {
6450
+ color: ${tokens.textSecondary};
6451
+ }
6452
+
6453
+ .svu-success-value {
6454
+ color: ${tokens.textPrimary};
6455
+ font-weight: 500;
6456
+ display: flex;
6457
+ align-items: center;
6458
+ gap: 8px;
6459
+ }
6460
+
6461
+ .svu-copy-btn {
6462
+ width: 24px;
6463
+ height: 24px;
6464
+ display: flex;
6465
+ align-items: center;
6466
+ justify-content: center;
6467
+ background: transparent;
6468
+ border: none;
6469
+ border-radius: 4px;
6470
+ cursor: pointer;
6471
+ color: ${tokens.textMuted};
6472
+ transition: background 0.15s ease, color 0.15s ease;
6473
+ }
6474
+
6475
+ .svu-copy-btn:hover {
6476
+ background: ${tokens.bgHover};
6477
+ color: ${tokens.primary};
6478
+ }
6479
+
6480
+ .svu-success-guidance {
6481
+ font-size: 13px;
6482
+ color: ${tokens.textSecondary};
6483
+ text-align: center;
6484
+ margin: 0 0 20px 0;
6485
+ line-height: 1.5;
6486
+ }
6487
+
6488
+ /* ========================================
6489
+ ERROR VIEW
6490
+ ======================================== */
6491
+
6492
+ .svu-error-view {
6493
+ display: flex;
6494
+ flex-direction: column;
6495
+ align-items: center;
6496
+ text-align: center;
6497
+ }
6498
+
6499
+ .svu-error-icon {
6500
+ color: ${tokens.error};
6501
+ margin-bottom: 16px;
6502
+ }
6503
+
6504
+ .svu-error-title {
6505
+ font-size: 18px;
6506
+ font-weight: 600;
6507
+ color: ${tokens.textPrimary};
6508
+ margin: 0 0 8px 0;
6509
+ }
6510
+
6511
+ .svu-error-message {
6512
+ font-size: 14px;
6513
+ color: ${tokens.textSecondary};
6514
+ margin: 0 0 16px 0;
6515
+ line-height: 1.5;
6516
+ }
6517
+
6518
+ .svu-error-code {
6519
+ display: inline-flex;
6520
+ align-items: center;
6521
+ gap: 6px;
6522
+ padding: 6px 10px;
6523
+ background: ${tokens.errorLight};
6524
+ border: 1px solid ${tokens.errorBorder};
6525
+ border-radius: 6px;
6526
+ margin-bottom: 20px;
6527
+ }
6528
+
6529
+ .svu-error-code-label {
6530
+ font-size: 10px;
6531
+ font-weight: 600;
6532
+ color: #991B1B;
6533
+ text-transform: uppercase;
6534
+ letter-spacing: 0.05em;
6535
+ }
6536
+
6537
+ .svu-error-code-value {
6538
+ font-size: 11px;
6539
+ font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Monaco, monospace;
6540
+ color: ${tokens.error};
6541
+ font-weight: 500;
6542
+ }
6543
+
6544
+ /* ========================================
6545
+ SECURITY SIDEBAR
6546
+ ======================================== */
6547
+
6548
+ .svu-sidebar {
6549
+ width: 320px;
6550
+ background: ${tokens.bgDark};
6551
+ border-left: 1px solid ${tokens.borderDark};
6552
+ color: ${tokens.textOnDark};
6553
+ padding: 20px;
6554
+ overflow-y: auto;
6555
+ flex-shrink: 0;
6556
+ }
6557
+
6558
+ .svu-sidebar-toggle {
6559
+ display: flex;
6560
+ align-items: center;
6561
+ gap: 4px;
6562
+ background: transparent;
6563
+ border: none;
6564
+ color: ${tokens.textMutedOnDark};
6565
+ font-size: 12px;
6566
+ cursor: pointer;
6567
+ padding: 0;
6568
+ margin-bottom: 16px;
6569
+ }
6570
+
6571
+ .svu-sidebar-toggle:hover {
6572
+ color: ${tokens.textOnDark};
6573
+ }
6574
+
6575
+ .svu-sidebar-logo {
6576
+ margin-bottom: 20px;
6577
+ }
6578
+
6579
+ .svu-sidebar-logo img {
6580
+ height: 24px;
6581
+ width: auto;
6582
+ }
6583
+
6584
+ .svu-sidebar-intro h3 {
6585
+ font-size: 14px;
6586
+ font-weight: 600;
6587
+ margin: 0 0 8px 0;
6588
+ }
6589
+
6590
+ .svu-sidebar-intro p {
6591
+ font-size: 12px;
6592
+ color: ${tokens.textMutedOnDark};
6593
+ line-height: 1.5;
6594
+ margin: 0 0 20px 0;
6595
+ }
6596
+
6597
+ .svu-sidebar-security h4 {
6598
+ display: flex;
6599
+ align-items: center;
6600
+ gap: 8px;
6601
+ font-size: 12px;
6602
+ font-weight: 600;
6603
+ margin: 0 0 12px 0;
6604
+ }
6605
+
6606
+ .svu-security-intro {
6607
+ font-size: 11px;
6608
+ color: ${tokens.textMutedOnDark};
6609
+ line-height: 1.5;
6610
+ margin: 0 0 16px 0;
6611
+ }
6612
+
6613
+ .svu-security-intro strong {
6614
+ color: ${tokens.textOnDark};
6615
+ }
6616
+
6617
+ .svu-key-chain {
6618
+ display: flex;
6619
+ flex-direction: column;
6620
+ gap: 8px;
6621
+ margin-bottom: 16px;
6622
+ }
6623
+
6624
+ .svu-key-item {
6625
+ display: flex;
6626
+ gap: 12px;
6627
+ padding: 10px;
6628
+ background: ${tokens.bgDarkSubtle};
6629
+ border-radius: 6px;
6630
+ }
6631
+
6632
+ .svu-key-number {
6633
+ width: 20px;
6634
+ height: 20px;
6635
+ background: ${tokens.primary};
6636
+ color: ${tokens.textOnPrimary};
6637
+ border-radius: 50%;
6638
+ font-size: 11px;
6639
+ font-weight: 600;
6640
+ display: flex;
6641
+ align-items: center;
6642
+ justify-content: center;
6643
+ flex-shrink: 0;
6644
+ }
6645
+
6646
+ .svu-key-info {
6647
+ flex: 1;
6648
+ }
6649
+
6650
+ .svu-key-name {
6651
+ display: block;
6652
+ font-size: 11px;
6653
+ font-weight: 600;
6654
+ color: ${tokens.textOnDark};
6655
+ margin-bottom: 2px;
6656
+ }
6657
+
6658
+ .svu-key-algo {
6659
+ display: block;
6660
+ font-size: 10px;
6661
+ color: ${tokens.primary};
6662
+ margin-bottom: 4px;
6663
+ }
6664
+
6665
+ .svu-key-desc {
6666
+ display: block;
6667
+ font-size: 10px;
6668
+ color: ${tokens.textMutedOnDark};
6669
+ line-height: 1.4;
6670
+ }
6671
+
6672
+ .svu-key-connector {
6673
+ display: flex;
6674
+ justify-content: center;
6675
+ color: ${tokens.textMutedOnDark};
6676
+ }
6677
+
6678
+ .svu-encryption-result {
6679
+ display: flex;
6680
+ gap: 8px;
6681
+ font-size: 10px;
6682
+ color: ${tokens.textMutedOnDark};
6683
+ line-height: 1.5;
6684
+ padding: 10px;
6685
+ background: ${tokens.bgDarkSubtle};
6686
+ border-radius: 6px;
6687
+ margin-bottom: 16px;
6688
+ }
6689
+
6690
+ .svu-encryption-result svg {
6691
+ color: ${tokens.success};
6692
+ flex-shrink: 0;
6693
+ }
6694
+
6695
+ .svu-encryption-result strong {
6696
+ color: ${tokens.textOnDark};
6697
+ }
6698
+
6699
+ .svu-security-badges {
6700
+ display: flex;
6701
+ flex-wrap: wrap;
6702
+ gap: 6px;
6703
+ }
6704
+
6705
+ .svu-tls-badge {
6706
+ font-size: 9px;
6707
+ font-weight: 500;
6708
+ padding: 4px 8px;
6709
+ background: ${tokens.bgDarkSubtle};
6710
+ border-radius: 4px;
6711
+ color: ${tokens.textMutedOnDark};
6712
+ }
6713
+
6714
+ /* ========================================
6715
+ INLINE CONTAINER
6716
+ ======================================== */
6717
+
6718
+ .svu-inline-container {
6719
+ display: flex;
6720
+ flex-direction: column;
6721
+ width: 100%;
6722
+ height: 100%;
6723
+ box-sizing: border-box;
6724
+ background: ${tokens.bg};
6725
+ color: ${tokens.textPrimary};
6726
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
6727
+ -webkit-font-smoothing: antialiased;
6728
+ -moz-osx-font-smoothing: grayscale;
6729
+ border-radius: 12px;
6730
+ border: 1px solid ${tokens.border};
6731
+ overflow: hidden;
6732
+ }
6733
+
6734
+ .svu-inline-container.svu-dark {
6735
+ background: ${tokens.bgDark};
6736
+ color: ${tokens.textOnDark};
6737
+ border-color: ${tokens.borderDark};
6738
+ }
6739
+
6740
+ /* ========================================
6741
+ RESPONSIVE
6742
+ ======================================== */
6743
+
6744
+ @media (max-width: 768px) {
6745
+ .svu-modal.svu-with-sidebar {
6746
+ flex-direction: column;
6747
+ max-width: 480px;
6748
+ }
6749
+
6750
+ .svu-sidebar {
6751
+ width: 100%;
6752
+ border-left: none;
6753
+ border-top: 1px solid ${tokens.borderDark};
6754
+ max-height: 300px;
6755
+ }
6756
+ }
6757
+
6758
+ @media (max-width: 480px) {
6759
+ .svu-modal {
6760
+ max-width: 100%;
6761
+ max-height: 100%;
6762
+ border-radius: 0;
6763
+ }
6764
+
6765
+ .svu-overlay {
6766
+ padding: 0;
6767
+ }
6768
+
6769
+ .svu-body {
6770
+ padding: 20px;
6771
+ }
6772
+ }
6773
+
6774
+ /* ========================================
6775
+ ACCESSIBILITY
6776
+ ======================================== */
6777
+
6778
+ .svu-sr-only {
6779
+ position: absolute;
6780
+ width: 1px;
6781
+ height: 1px;
6782
+ padding: 0;
6783
+ margin: -1px;
6784
+ overflow: hidden;
6785
+ clip: rect(0, 0, 0, 0);
6786
+ white-space: nowrap;
6787
+ border: 0;
6788
+ }
6789
+ `;
6790
+ }
6791
+
6792
+ /**
6793
+ * Upload Modal Container
6794
+ *
6795
+ * Single responsibility: DOM modal container lifecycle.
6796
+ * Creates, shows, hides, and destroys the modal overlay and container.
6797
+ * Does NOT handle content rendering - that's the Renderer's job.
6798
+ */
6799
+ const OVERLAY_ID = 'sparkvault-upload-overlay';
6800
+ const MODAL_ID = 'sparkvault-upload-modal';
6801
+ /**
6802
+ * Default branding used for immediate modal display before config loads.
6803
+ */
6804
+ const DEFAULT_BRANDING = {
6805
+ organizationName: '',
6806
+ logoLightUrl: null,
6807
+ logoDarkUrl: null,
6808
+ };
6809
+ class UploadModalContainer {
6810
+ constructor() {
6811
+ this.elements = null;
6812
+ this.onCloseCallback = null;
6813
+ this.keydownHandler = null;
6814
+ this.overlayClickHandler = null;
6815
+ this.closeBtnClickHandler = null;
6816
+ this.closeBtn = null;
6817
+ this.backdropBlur = true;
6818
+ this.isDarkMode = false;
6819
+ this.headerElement = null;
6820
+ this.branding = DEFAULT_BRANDING;
6821
+ }
6822
+ /**
6823
+ * Create and show the modal immediately with loading state.
6824
+ */
6825
+ createLoading(options, onClose) {
6826
+ this.create({
6827
+ branding: DEFAULT_BRANDING,
6828
+ backdropBlur: options.backdropBlur,
6829
+ }, onClose);
6830
+ }
6831
+ /**
6832
+ * Create and show the modal container.
6833
+ */
6834
+ create(options, onClose) {
6835
+ if (this.elements) {
6836
+ return;
6837
+ }
6838
+ this.onCloseCallback = onClose;
6839
+ this.backdropBlur = options.backdropBlur !== false;
6840
+ this.branding = options.branding || DEFAULT_BRANDING;
6841
+ injectUploadStyles({
6842
+ branding: this.branding,
6843
+ backdropBlur: this.backdropBlur,
6844
+ });
6845
+ // Create overlay
6846
+ const overlay = document.createElement('div');
6847
+ overlay.id = OVERLAY_ID;
6848
+ overlay.className = this.backdropBlur ? 'svu-overlay svu-blur' : 'svu-overlay';
6849
+ overlay.setAttribute('role', 'dialog');
6850
+ overlay.setAttribute('aria-modal', 'true');
6851
+ overlay.setAttribute('aria-labelledby', 'svu-modal-title');
6852
+ // Create modal
6853
+ const modal = document.createElement('div');
6854
+ modal.id = MODAL_ID;
6855
+ modal.className = 'svu-modal';
6856
+ // Create main content area
6857
+ const main = document.createElement('div');
6858
+ main.className = 'svu-modal-main';
6859
+ // Create header
6860
+ const header = this.createHeader(this.branding);
6861
+ this.headerElement = header;
6862
+ // Create body
6863
+ const body = document.createElement('div');
6864
+ body.className = 'svu-body';
6865
+ // Create footer
6866
+ const footer = document.createElement('div');
6867
+ footer.className = 'svu-footer';
6868
+ const footerText = document.createElement('span');
6869
+ footerText.className = 'svu-footer-text';
6870
+ footerText.appendChild(document.createTextNode('Secured by '));
6871
+ const footerLink = document.createElement('a');
6872
+ footerLink.href = 'https://sparkvault.com';
6873
+ footerLink.target = '_blank';
6874
+ footerLink.rel = 'noopener noreferrer';
6875
+ footerLink.className = 'svu-footer-link';
6876
+ footerLink.textContent = 'SparkVault';
6877
+ footerText.appendChild(footerLink);
6878
+ footer.appendChild(footerText);
6879
+ main.appendChild(header);
6880
+ main.appendChild(body);
6881
+ main.appendChild(footer);
6882
+ modal.appendChild(main);
6883
+ overlay.appendChild(modal);
6884
+ // Event handlers
6885
+ this.overlayClickHandler = (e) => {
6886
+ if (e.target === overlay) {
6887
+ this.handleClose();
6888
+ }
6889
+ };
6890
+ overlay.addEventListener('click', this.overlayClickHandler);
6891
+ this.keydownHandler = (e) => {
6892
+ if (e.key === 'Escape') {
6893
+ this.handleClose();
6894
+ }
6895
+ };
6896
+ document.addEventListener('keydown', this.keydownHandler);
6897
+ document.body.appendChild(overlay);
6898
+ document.body.style.overflow = 'hidden';
6899
+ this.elements = { overlay, modal, main, header, body, footer, sidebar: null };
6900
+ }
6901
+ createHeader(branding) {
6902
+ const header = document.createElement('div');
6903
+ header.className = 'svu-header';
6904
+ const titleContainer = document.createElement('div');
6905
+ titleContainer.className = 'svu-header-title';
6906
+ // Use light logo for light theme (default), dark logo for dark mode
6907
+ const logoUrl = this.isDarkMode
6908
+ ? branding.logoDarkUrl || branding.logoLightUrl
6909
+ : branding.logoLightUrl || branding.logoDarkUrl;
6910
+ if (logoUrl) {
6911
+ const logo = document.createElement('img');
6912
+ logo.className = 'svu-logo';
6913
+ logo.src = logoUrl;
6914
+ logo.alt = branding.organizationName;
6915
+ titleContainer.appendChild(logo);
6916
+ // Screen reader title
6917
+ const srTitle = document.createElement('span');
6918
+ srTitle.className = 'svu-sr-only';
6919
+ srTitle.id = 'svu-modal-title';
6920
+ srTitle.textContent = branding.organizationName;
6921
+ titleContainer.appendChild(srTitle);
6922
+ }
6923
+ else if (branding.organizationName) {
6924
+ const companyName = document.createElement('h2');
6925
+ companyName.className = 'svu-company-name';
6926
+ companyName.id = 'svu-modal-title';
6927
+ companyName.textContent = branding.organizationName;
6928
+ titleContainer.appendChild(companyName);
6929
+ }
6930
+ // Close button
6931
+ this.closeBtn = document.createElement('button');
6932
+ this.closeBtn.className = 'svu-close-btn';
6933
+ this.closeBtn.setAttribute('aria-label', 'Close');
6934
+ this.closeBtn.innerHTML = `
6935
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
6936
+ <path d="M18 6L6 18M6 6l12 12"/>
6937
+ </svg>
6938
+ `;
6939
+ this.closeBtnClickHandler = () => this.handleClose();
6940
+ this.closeBtn.addEventListener('click', this.closeBtnClickHandler);
6941
+ header.appendChild(titleContainer);
6942
+ header.appendChild(this.closeBtn);
6943
+ return header;
6944
+ }
6945
+ /**
6946
+ * Update branding after vault config loads.
6947
+ */
6948
+ updateBranding(branding) {
6949
+ if (!this.elements)
6950
+ return;
6951
+ this.branding = branding;
6952
+ // Update header with real branding
6953
+ const newHeader = this.createHeader(branding);
6954
+ if (this.headerElement?.parentNode) {
6955
+ this.headerElement.parentNode.replaceChild(newHeader, this.headerElement);
6956
+ }
6957
+ this.headerElement = newHeader;
6958
+ }
6959
+ /**
6960
+ * Update backdrop blur setting.
6961
+ */
6962
+ updateBackdropBlur(enabled) {
6963
+ if (!this.elements)
6964
+ return;
6965
+ this.backdropBlur = enabled;
6966
+ if (enabled) {
6967
+ this.elements.overlay.classList.add('svu-blur');
6968
+ }
6969
+ else {
6970
+ this.elements.overlay.classList.remove('svu-blur');
6971
+ }
6972
+ }
6973
+ /**
6974
+ * Set dark mode for uploading/ceremony states.
6975
+ */
6976
+ setDarkMode(enabled) {
6977
+ if (!this.elements)
6978
+ return;
6979
+ this.isDarkMode = enabled;
6980
+ if (enabled) {
6981
+ this.elements.modal.classList.add('svu-dark');
6982
+ }
6983
+ else {
6984
+ this.elements.modal.classList.remove('svu-dark');
6985
+ }
6986
+ // Re-create header with correct logo
6987
+ const newHeader = this.createHeader(this.branding);
6988
+ if (this.headerElement?.parentNode) {
6989
+ this.headerElement.parentNode.replaceChild(newHeader, this.headerElement);
6990
+ }
6991
+ this.headerElement = newHeader;
6992
+ }
6993
+ /**
6994
+ * Show or hide the security sidebar.
6995
+ */
6996
+ toggleSidebar(show) {
6997
+ if (!this.elements)
6998
+ return;
6999
+ if (show && !this.elements.sidebar) {
7000
+ // Create sidebar
7001
+ const sidebar = this.createSecuritySidebar();
7002
+ this.elements.modal.classList.add('svu-with-sidebar');
7003
+ this.elements.modal.appendChild(sidebar);
7004
+ this.elements.sidebar = sidebar;
7005
+ }
7006
+ else if (!show && this.elements.sidebar) {
7007
+ // Remove sidebar
7008
+ this.elements.modal.classList.remove('svu-with-sidebar');
7009
+ this.elements.sidebar.remove();
7010
+ this.elements.sidebar = null;
7011
+ }
7012
+ }
7013
+ createSecuritySidebar() {
7014
+ const sidebar = document.createElement('div');
7015
+ sidebar.className = 'svu-sidebar';
7016
+ // Logo
7017
+ const logoDiv = document.createElement('div');
7018
+ logoDiv.className = 'svu-sidebar-logo';
7019
+ if (this.branding.logoDarkUrl) {
7020
+ const logo = document.createElement('img');
7021
+ logo.src = this.branding.logoDarkUrl;
7022
+ logo.alt = this.branding.organizationName;
7023
+ logoDiv.appendChild(logo);
7024
+ }
7025
+ sidebar.appendChild(logoDiv);
7026
+ // Intro
7027
+ const intro = document.createElement('div');
7028
+ intro.className = 'svu-sidebar-intro';
7029
+ intro.innerHTML = `
7030
+ <h3>Secure File Transfer</h3>
7031
+ <p>This file is secured with SparkVault's advanced multi-key cryptography and end-to-end encryption.</p>
7032
+ `;
7033
+ sidebar.appendChild(intro);
7034
+ // Security details
7035
+ const security = document.createElement('div');
7036
+ security.className = 'svu-sidebar-security';
7037
+ security.innerHTML = `
7038
+ <h4>
7039
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
7040
+ <path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
7041
+ </svg>
7042
+ SparkVault's Triple Zero-Trust
7043
+ </h4>
7044
+ <p class="svu-security-intro">
7045
+ Decryption requires <strong>three independent Master Keys</strong>, held in three different physical locations, by three separate companies, all cryptographically combined in real-time.
7046
+ </p>
7047
+ <div class="svu-key-chain">
7048
+ <div class="svu-key-item">
7049
+ <div class="svu-key-number">1</div>
7050
+ <div class="svu-key-info">
7051
+ <span class="svu-key-name">Kyber-1024 Post-Quantum Key</span>
7052
+ <span class="svu-key-algo">ML-KEM lattice-based encapsulation</span>
7053
+ <span class="svu-key-desc">Quantum-resistant key exchange</span>
7054
+ </div>
7055
+ </div>
7056
+ <div class="svu-key-connector">
7057
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
7058
+ <path d="M12 5v14M5 12h14"/>
7059
+ </svg>
7060
+ </div>
7061
+ <div class="svu-key-item">
7062
+ <div class="svu-key-number">2</div>
7063
+ <div class="svu-key-info">
7064
+ <span class="svu-key-name">X25519 Ephemeral Key</span>
7065
+ <span class="svu-key-algo">Elliptic-curve Diffie-Hellman</span>
7066
+ <span class="svu-key-desc">Perfect forward secrecy</span>
7067
+ </div>
7068
+ </div>
7069
+ <div class="svu-key-connector">
7070
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
7071
+ <path d="M12 5v14M5 12h14"/>
7072
+ </svg>
7073
+ </div>
7074
+ <div class="svu-key-item">
7075
+ <div class="svu-key-number">3</div>
7076
+ <div class="svu-key-info">
7077
+ <span class="svu-key-name">HKDF-SHA512 Derived Key</span>
7078
+ <span class="svu-key-algo">Hardware-bound key derivation</span>
7079
+ <span class="svu-key-desc">Vault master key in secure enclave</span>
7080
+ </div>
7081
+ </div>
7082
+ </div>
7083
+ <div class="svu-encryption-result">
7084
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
7085
+ <path d="M20 6L9 17l-5-5"/>
7086
+ </svg>
7087
+ <span>Combined via <strong>HKDF</strong> producing <strong>AES-256-GCM</strong> authenticated cipher</span>
7088
+ </div>
7089
+ <div class="svu-security-badges">
7090
+ <span class="svu-tls-badge">TLS 1.3 In Transit</span>
7091
+ <span class="svu-tls-badge">Encrypted At Rest</span>
7092
+ <span class="svu-tls-badge">Zero Knowledge</span>
7093
+ </div>
7094
+ `;
7095
+ sidebar.appendChild(security);
7096
+ return sidebar;
7097
+ }
7098
+ handleClose() {
7099
+ if (this.onCloseCallback) {
7100
+ this.onCloseCallback();
7101
+ }
7102
+ }
7103
+ /**
7104
+ * Get the modal body element for content rendering.
7105
+ */
7106
+ getBody() {
7107
+ return this.elements?.body ?? null;
7108
+ }
7109
+ /**
7110
+ * Get the sidebar element.
7111
+ */
7112
+ getSidebar() {
7113
+ return this.elements?.sidebar ?? null;
7114
+ }
7115
+ /**
7116
+ * Check if the modal is currently open.
7117
+ */
7118
+ isOpen() {
7119
+ return this.elements !== null;
7120
+ }
7121
+ /**
7122
+ * Destroy the modal and clean up all event listeners.
7123
+ */
7124
+ destroy() {
7125
+ if (this.keydownHandler) {
7126
+ document.removeEventListener('keydown', this.keydownHandler);
7127
+ this.keydownHandler = null;
7128
+ }
7129
+ if (this.elements && this.overlayClickHandler) {
7130
+ this.elements.overlay.removeEventListener('click', this.overlayClickHandler);
7131
+ this.overlayClickHandler = null;
7132
+ }
7133
+ if (this.closeBtn && this.closeBtnClickHandler) {
7134
+ this.closeBtn.removeEventListener('click', this.closeBtnClickHandler);
7135
+ this.closeBtnClickHandler = null;
7136
+ this.closeBtn = null;
7137
+ }
7138
+ if (this.elements) {
7139
+ this.elements.overlay.remove();
7140
+ this.elements = null;
7141
+ }
7142
+ this.headerElement = null;
7143
+ document.body.style.overflow = '';
7144
+ this.onCloseCallback = null;
7145
+ }
7146
+ }
7147
+
7148
+ /**
7149
+ * Inline Upload Container
7150
+ *
7151
+ * Renders upload UI inline within a target element.
7152
+ * Unlike UploadModalContainer which creates an overlay popup, this embeds
7153
+ * directly into the customer's page where render() was called.
7154
+ *
7155
+ * Implements the UploadContainer interface for use with UploadRenderer.
7156
+ */
7157
+ const DEFAULT_OPTIONS = {
7158
+ showHeader: true,
7159
+ showCloseButton: true,
7160
+ showFooter: true,
7161
+ };
7162
+ class UploadInlineContainer {
7163
+ constructor(targetElement, options = {}) {
7164
+ this.container = null;
7165
+ this.header = null;
7166
+ this.body = null;
7167
+ this.footer = null;
7168
+ this.sidebar = null;
7169
+ this.closeBtn = null;
7170
+ this.onCloseCallback = null;
7171
+ this.closeBtnClickHandler = null;
7172
+ this.isDarkMode = false;
7173
+ this.branding = {
7174
+ organizationName: '',
7175
+ logoLightUrl: null,
7176
+ logoDarkUrl: null,
7177
+ };
7178
+ this.targetElement = targetElement;
7179
+ this.containerOptions = { ...DEFAULT_OPTIONS, ...options };
7180
+ }
7181
+ /**
7182
+ * Create the inline container with loading state.
7183
+ */
7184
+ createLoading(_options, onClose) {
7185
+ if (this.container) {
7186
+ return;
7187
+ }
7188
+ this.onCloseCallback = onClose;
7189
+ // Inject styles
7190
+ injectUploadStyles({
7191
+ branding: this.branding,
7192
+ backdropBlur: false,
7193
+ });
7194
+ // Create main container
7195
+ this.container = document.createElement('div');
7196
+ this.container.className = 'svu-inline-container';
7197
+ // Create header if enabled
7198
+ if (this.containerOptions.showHeader) {
7199
+ this.header = this.createHeader(this.branding);
7200
+ this.container.appendChild(this.header);
7201
+ }
7202
+ // Create body for view content
7203
+ this.body = document.createElement('div');
7204
+ this.body.className = 'svu-body';
7205
+ this.container.appendChild(this.body);
7206
+ // Create footer if enabled
7207
+ if (this.containerOptions.showFooter) {
7208
+ this.footer = document.createElement('div');
7209
+ this.footer.className = 'svu-footer';
7210
+ const footerText = document.createElement('span');
7211
+ footerText.className = 'svu-footer-text';
7212
+ footerText.appendChild(document.createTextNode('Secured by '));
7213
+ const footerLink = document.createElement('a');
7214
+ footerLink.href = 'https://sparkvault.com';
7215
+ footerLink.target = '_blank';
7216
+ footerLink.rel = 'noopener noreferrer';
7217
+ footerLink.className = 'svu-footer-link';
7218
+ footerLink.textContent = 'SparkVault';
7219
+ footerText.appendChild(footerLink);
7220
+ this.footer.appendChild(footerText);
7221
+ this.container.appendChild(this.footer);
7222
+ }
7223
+ // Clear target and append container
7224
+ this.targetElement.innerHTML = '';
7225
+ this.targetElement.appendChild(this.container);
7226
+ }
7227
+ /**
7228
+ * Update branding after vault config loads.
7229
+ */
7230
+ updateBranding(branding) {
7231
+ if (!this.container)
7232
+ return;
7233
+ this.branding = branding;
7234
+ // Update header with real branding
7235
+ if (this.containerOptions.showHeader && this.header) {
7236
+ const newHeader = this.createHeader(branding);
7237
+ this.container.replaceChild(newHeader, this.header);
7238
+ this.header = newHeader;
7239
+ }
7240
+ }
7241
+ /**
7242
+ * Update backdrop blur setting (no-op for inline container).
7243
+ */
7244
+ updateBackdropBlur(_enabled) {
7245
+ // No-op - inline containers don't have backdrop blur
7246
+ }
7247
+ /**
7248
+ * Set dark mode for uploading/ceremony states.
7249
+ */
7250
+ setDarkMode(enabled) {
7251
+ if (!this.container)
7252
+ return;
7253
+ this.isDarkMode = enabled;
7254
+ if (enabled) {
7255
+ this.container.classList.add('svu-dark');
7256
+ }
7257
+ else {
7258
+ this.container.classList.remove('svu-dark');
7259
+ }
7260
+ // Re-create header with correct logo
7261
+ if (this.containerOptions.showHeader && this.header) {
7262
+ const newHeader = this.createHeader(this.branding);
7263
+ this.container.replaceChild(newHeader, this.header);
7264
+ this.header = newHeader;
7265
+ }
7266
+ }
7267
+ /**
7268
+ * Show or hide the security sidebar.
7269
+ */
7270
+ toggleSidebar(show) {
7271
+ if (!this.container)
7272
+ return;
7273
+ if (show && !this.sidebar) {
7274
+ // Create sidebar
7275
+ this.sidebar = this.createSecuritySidebar();
7276
+ this.container.classList.add('svu-with-sidebar');
7277
+ this.container.appendChild(this.sidebar);
7278
+ }
7279
+ else if (!show && this.sidebar) {
7280
+ // Remove sidebar
7281
+ this.container.classList.remove('svu-with-sidebar');
7282
+ this.sidebar.remove();
7283
+ this.sidebar = null;
7284
+ }
7285
+ }
7286
+ /**
7287
+ * Get the body element for content rendering.
7288
+ */
7289
+ getBody() {
7290
+ return this.body;
7291
+ }
7292
+ /**
7293
+ * Get the sidebar element.
7294
+ */
7295
+ getSidebar() {
7296
+ return this.sidebar;
7297
+ }
7298
+ /**
7299
+ * Check if the container is currently active.
7300
+ */
7301
+ isOpen() {
7302
+ return this.container !== null;
7303
+ }
7304
+ /**
7305
+ * Destroy the container and clean up.
7306
+ */
7307
+ destroy() {
7308
+ // Clean up close button handler
7309
+ if (this.closeBtn && this.closeBtnClickHandler) {
7310
+ this.closeBtn.removeEventListener('click', this.closeBtnClickHandler);
7311
+ this.closeBtnClickHandler = null;
7312
+ this.closeBtn = null;
7313
+ }
7314
+ // Clear the target element
7315
+ if (this.container && this.container.parentNode) {
7316
+ this.container.remove();
7317
+ }
7318
+ this.container = null;
7319
+ this.header = null;
7320
+ this.body = null;
7321
+ this.footer = null;
7322
+ this.sidebar = null;
7323
+ this.onCloseCallback = null;
7324
+ }
7325
+ createHeader(branding) {
7326
+ const header = document.createElement('div');
7327
+ header.className = 'svu-header';
7328
+ const titleContainer = document.createElement('div');
7329
+ titleContainer.className = 'svu-header-title';
7330
+ // Use appropriate logo based on dark mode
7331
+ const logoUrl = this.isDarkMode
7332
+ ? branding.logoDarkUrl || branding.logoLightUrl
7333
+ : branding.logoLightUrl || branding.logoDarkUrl;
7334
+ if (logoUrl) {
7335
+ const logo = document.createElement('img');
7336
+ logo.className = 'svu-logo';
7337
+ logo.src = logoUrl;
7338
+ logo.alt = branding.organizationName;
7339
+ titleContainer.appendChild(logo);
7340
+ // Screen reader title
7341
+ const srTitle = document.createElement('span');
7342
+ srTitle.className = 'svu-sr-only';
7343
+ srTitle.textContent = branding.organizationName;
7344
+ titleContainer.appendChild(srTitle);
7345
+ }
7346
+ else if (branding.organizationName) {
7347
+ const companyName = document.createElement('h2');
7348
+ companyName.className = 'svu-company-name';
7349
+ companyName.textContent = branding.organizationName;
7350
+ titleContainer.appendChild(companyName);
7351
+ }
7352
+ header.appendChild(titleContainer);
7353
+ // Add close button if enabled
7354
+ if (this.containerOptions.showCloseButton) {
7355
+ this.closeBtn = document.createElement('button');
7356
+ this.closeBtn.className = 'svu-close-btn';
7357
+ this.closeBtn.setAttribute('aria-label', 'Close');
7358
+ this.closeBtn.innerHTML = `
7359
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
7360
+ <path d="M18 6L6 18M6 6l12 12"/>
7361
+ </svg>
7362
+ `;
7363
+ this.closeBtnClickHandler = () => this.handleClose();
7364
+ this.closeBtn.addEventListener('click', this.closeBtnClickHandler);
7365
+ header.appendChild(this.closeBtn);
7366
+ }
7367
+ return header;
7368
+ }
7369
+ createSecuritySidebar() {
7370
+ const sidebar = document.createElement('div');
7371
+ sidebar.className = 'svu-sidebar';
7372
+ // Logo
7373
+ const logoDiv = document.createElement('div');
7374
+ logoDiv.className = 'svu-sidebar-logo';
7375
+ if (this.branding.logoDarkUrl) {
7376
+ const logo = document.createElement('img');
7377
+ logo.src = this.branding.logoDarkUrl;
7378
+ logo.alt = this.branding.organizationName;
7379
+ logoDiv.appendChild(logo);
7380
+ }
7381
+ sidebar.appendChild(logoDiv);
7382
+ // Intro
7383
+ const intro = document.createElement('div');
7384
+ intro.className = 'svu-sidebar-intro';
7385
+ intro.innerHTML = `
7386
+ <h3>Secure File Transfer</h3>
7387
+ <p>This file is secured with SparkVault's advanced multi-key cryptography and end-to-end encryption.</p>
7388
+ `;
7389
+ sidebar.appendChild(intro);
7390
+ // Security details
7391
+ const security = document.createElement('div');
7392
+ security.className = 'svu-sidebar-security';
7393
+ security.innerHTML = `
7394
+ <h4>
7395
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
7396
+ <path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
7397
+ </svg>
7398
+ SparkVault's Triple Zero-Trust
7399
+ </h4>
7400
+ <p class="svu-security-intro">
7401
+ Decryption requires <strong>three independent Master Keys</strong>, held in three different physical locations, by three separate companies, all cryptographically combined in real-time.
7402
+ </p>
7403
+ <div class="svu-key-chain">
7404
+ <div class="svu-key-item">
7405
+ <div class="svu-key-number">1</div>
7406
+ <div class="svu-key-info">
7407
+ <span class="svu-key-name">Kyber-1024 Post-Quantum Key</span>
7408
+ <span class="svu-key-algo">ML-KEM lattice-based encapsulation</span>
7409
+ <span class="svu-key-desc">Quantum-resistant key exchange</span>
7410
+ </div>
7411
+ </div>
7412
+ <div class="svu-key-connector">
7413
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
7414
+ <path d="M12 5v14M5 12h14"/>
7415
+ </svg>
7416
+ </div>
7417
+ <div class="svu-key-item">
7418
+ <div class="svu-key-number">2</div>
7419
+ <div class="svu-key-info">
7420
+ <span class="svu-key-name">X25519 Ephemeral Key</span>
7421
+ <span class="svu-key-algo">Elliptic-curve Diffie-Hellman</span>
7422
+ <span class="svu-key-desc">Perfect forward secrecy</span>
7423
+ </div>
7424
+ </div>
7425
+ <div class="svu-key-connector">
7426
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
7427
+ <path d="M12 5v14M5 12h14"/>
7428
+ </svg>
7429
+ </div>
7430
+ <div class="svu-key-item">
7431
+ <div class="svu-key-number">3</div>
7432
+ <div class="svu-key-info">
7433
+ <span class="svu-key-name">HKDF-SHA512 Derived Key</span>
7434
+ <span class="svu-key-algo">Hardware-bound key derivation</span>
7435
+ <span class="svu-key-desc">Vault master key in secure enclave</span>
7436
+ </div>
7437
+ </div>
7438
+ </div>
7439
+ <div class="svu-encryption-result">
7440
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
7441
+ <path d="M20 6L9 17l-5-5"/>
7442
+ </svg>
7443
+ <span>Combined via <strong>HKDF</strong> producing <strong>AES-256-GCM</strong> authenticated cipher</span>
7444
+ </div>
7445
+ <div class="svu-security-badges">
7446
+ <span class="svu-tls-badge">TLS 1.3 In Transit</span>
7447
+ <span class="svu-tls-badge">Encrypted At Rest</span>
7448
+ <span class="svu-tls-badge">Zero Knowledge</span>
7449
+ </div>
7450
+ `;
7451
+ sidebar.appendChild(security);
7452
+ return sidebar;
7453
+ }
7454
+ handleClose() {
7455
+ if (this.onCloseCallback) {
7456
+ this.onCloseCallback();
7457
+ }
7458
+ }
7459
+ }
7460
+
7461
+ /**
7462
+ * Upload Renderer
7463
+ *
7464
+ * Orchestrates view state and rendering for vault upload flows.
7465
+ * Manages the complete upload lifecycle: form, uploading, ceremony, completion.
7466
+ */
7467
+ /**
7468
+ * Ceremony steps for the cryptographic visualization.
7469
+ */
7470
+ const CEREMONY_STEPS = [
7471
+ { text: 'File received by Forge', duration: 600 },
7472
+ { text: 'Deriving Triple Zero-Trust encryption keys', duration: 800 },
7473
+ { text: 'Encrypting with AES-256-GCM', duration: 900 },
7474
+ { text: 'Generating cryptographic signatures', duration: 700 },
7475
+ { text: 'Storing in post-quantum protected vault', duration: 600 },
7476
+ { text: 'Verifying integrity', duration: 400 },
7477
+ ];
7478
+ /**
7479
+ * Convert hex string to base64url for display.
7480
+ */
7481
+ function hexToBase64url(hex) {
7482
+ const bytes = new Uint8Array(hex.match(/.{2}/g).map(byte => parseInt(byte, 16)));
7483
+ const base64 = btoa(String.fromCharCode(...bytes));
7484
+ return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
7485
+ }
7486
+ /**
7487
+ * Generate a request ID for display.
7488
+ */
7489
+ function generateRequestId() {
7490
+ const timestamp = Date.now().toString(16);
7491
+ const random = Math.random().toString(16).substring(2, 10);
7492
+ return `${timestamp}${random}`;
7493
+ }
7494
+ /**
7495
+ * Format bytes to human readable string.
7496
+ */
7497
+ function formatBytes(bytes) {
7498
+ if (bytes === 0)
7499
+ return '0 B';
7500
+ const k = 1024;
7501
+ const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
7502
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
7503
+ return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
7504
+ }
7505
+ class UploadRenderer {
7506
+ constructor(container, api, options, callbacks) {
7507
+ this.viewState = { view: 'loading' };
7508
+ this.config = null;
7509
+ this.selectedFile = null;
7510
+ // File input element for selection
7511
+ this.fileInputElement = null;
7512
+ this.pasteHandler = null;
7513
+ this.container = container;
7514
+ this.api = api;
7515
+ this.options = options;
7516
+ this.callbacks = callbacks;
7517
+ }
7518
+ /**
7519
+ * Start the upload flow.
7520
+ */
7521
+ async start() {
7522
+ // Create container with loading state
7523
+ this.container.createLoading({ backdropBlur: this.options.backdropBlur }, () => this.handleClose());
7524
+ this.setState({ view: 'loading' });
7525
+ try {
7526
+ // Minimum delay for UX
7527
+ const [config] = await Promise.all([
7528
+ this.api.getVaultUploadInfo(this.options.vaultId),
7529
+ new Promise(resolve => setTimeout(resolve, 1000)),
7530
+ ]);
7531
+ this.config = config;
7532
+ // Update branding
7533
+ this.container.updateBranding(config.branding);
7534
+ // Show form
7535
+ this.setState({ view: 'form', config });
7536
+ }
7537
+ catch (error) {
7538
+ this.handleApiError(error);
7539
+ }
7540
+ }
7541
+ /**
7542
+ * Close the upload flow.
7543
+ */
7544
+ close() {
7545
+ this.cleanupFileInput();
7546
+ this.cleanupPasteHandler();
7547
+ this.container.destroy();
7548
+ }
7549
+ handleClose() {
7550
+ this.close();
7551
+ this.callbacks.onCancel();
7552
+ }
7553
+ setState(state) {
7554
+ this.viewState = state;
7555
+ this.render();
7556
+ }
7557
+ render() {
7558
+ const body = this.container.getBody();
7559
+ if (!body)
7560
+ return;
7561
+ // Clear body
7562
+ body.innerHTML = '';
7563
+ // Handle dark mode for certain states
7564
+ const containerWithDarkMode = this.container;
7565
+ if ('setDarkMode' in containerWithDarkMode) {
7566
+ const isDarkState = this.viewState.view === 'uploading' || this.viewState.view === 'ceremony';
7567
+ containerWithDarkMode.setDarkMode(isDarkState);
7568
+ // Show sidebar during dark states (uploading/ceremony)
7569
+ this.container.toggleSidebar(isDarkState);
7570
+ }
7571
+ switch (this.viewState.view) {
7572
+ case 'loading':
7573
+ body.appendChild(this.renderLoading());
7574
+ break;
7575
+ case 'form':
7576
+ body.appendChild(this.renderForm(this.viewState.config, this.viewState.error));
7577
+ break;
7578
+ case 'uploading':
7579
+ body.appendChild(this.renderUploading(this.viewState));
7580
+ break;
7581
+ case 'ceremony':
7582
+ body.appendChild(this.renderCeremony(this.viewState));
7583
+ break;
7584
+ case 'complete':
7585
+ body.appendChild(this.renderComplete(this.viewState.result, this.viewState.config));
7586
+ break;
7587
+ case 'error':
7588
+ body.appendChild(this.renderError(this.viewState));
7589
+ break;
7590
+ }
7591
+ }
7592
+ renderLoading() {
7593
+ const div = document.createElement('div');
7594
+ div.className = 'svu-loading';
7595
+ div.innerHTML = `
7596
+ <div class="svu-spinner"></div>
7597
+ <p class="svu-loading-text">Establishing secure connection...</p>
7598
+ `;
7599
+ return div;
7600
+ }
7601
+ renderForm(config, error) {
7602
+ const div = document.createElement('div');
7603
+ div.className = 'svu-form-view';
7604
+ // Title
7605
+ const title = document.createElement('h2');
7606
+ title.className = 'svu-title';
7607
+ title.textContent = 'Cryptographically Secure File Transfer';
7608
+ div.appendChild(title);
7609
+ // Subtitle with vault name
7610
+ const subtitle = document.createElement('p');
7611
+ subtitle.className = 'svu-subtitle';
7612
+ subtitle.innerHTML = `Destination Vault: <strong>${this.escapeHtml(config.vaultName)}</strong>`;
7613
+ div.appendChild(subtitle);
7614
+ // Error message
7615
+ if (error) {
7616
+ const alert = document.createElement('div');
7617
+ alert.className = 'svu-error-alert';
7618
+ alert.textContent = error;
7619
+ div.appendChild(alert);
7620
+ }
7621
+ // Drop zone
7622
+ const dropZone = document.createElement('div');
7623
+ dropZone.className = `svu-drop-zone${this.selectedFile ? ' svu-has-file' : ''}`;
7624
+ if (this.selectedFile) {
7625
+ dropZone.innerHTML = `
7626
+ <div class="svu-selected-file">
7627
+ <div class="svu-file-icon">
7628
+ <svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
7629
+ <path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/>
7630
+ <path d="M14 2v6h6"/>
7631
+ </svg>
7632
+ </div>
7633
+ <div class="svu-file-info">
7634
+ <span class="svu-file-name">${this.escapeHtml(this.selectedFile.name)}</span>
7635
+ <span class="svu-file-size">${formatBytes(this.selectedFile.size)}</span>
7636
+ </div>
7637
+ <button class="svu-remove-file" aria-label="Remove file">&times;</button>
7638
+ </div>
7639
+ `;
7640
+ const removeBtn = dropZone.querySelector('.svu-remove-file');
7641
+ if (removeBtn) {
7642
+ removeBtn.addEventListener('click', (e) => {
7643
+ e.stopPropagation();
7644
+ this.selectedFile = null;
7645
+ this.render();
7646
+ });
7647
+ }
7648
+ }
7649
+ else {
7650
+ dropZone.innerHTML = `
7651
+ <div class="svu-drop-icon">
7652
+ <svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
7653
+ <path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/>
7654
+ <path d="M17 8l-5-5-5 5"/>
7655
+ <path d="M12 3v12"/>
7656
+ </svg>
7657
+ </div>
7658
+ <p class="svu-drop-text">Drag & drop a file here</p>
7659
+ <p class="svu-drop-subtext">or click to select</p>
7660
+ <p class="svu-drop-hint">Paste from clipboard also supported (Ctrl+V)</p>
7661
+ `;
7662
+ }
7663
+ // Drop zone events
7664
+ dropZone.addEventListener('dragover', (e) => {
7665
+ e.preventDefault();
7666
+ dropZone.classList.add('svu-dragging');
7667
+ });
7668
+ dropZone.addEventListener('dragleave', (e) => {
7669
+ e.preventDefault();
7670
+ dropZone.classList.remove('svu-dragging');
7671
+ });
7672
+ dropZone.addEventListener('drop', (e) => {
7673
+ e.preventDefault();
7674
+ dropZone.classList.remove('svu-dragging');
7675
+ const file = e.dataTransfer?.files[0];
7676
+ if (file) {
7677
+ this.handleFileSelect(file, config);
7678
+ }
7679
+ });
7680
+ dropZone.addEventListener('click', () => {
7681
+ if (!this.selectedFile) {
7682
+ this.openFileSelector(config);
7683
+ }
7684
+ });
7685
+ div.appendChild(dropZone);
7686
+ // Max size
7687
+ const maxSize = document.createElement('p');
7688
+ maxSize.className = 'svu-max-size';
7689
+ maxSize.textContent = `Max upload size: ${formatBytes(config.maxSizeBytes)}`;
7690
+ div.appendChild(maxSize);
7691
+ // Upload button
7692
+ if (this.selectedFile) {
7693
+ const uploadBtn = document.createElement('button');
7694
+ uploadBtn.className = 'svu-btn svu-btn-primary';
7695
+ uploadBtn.textContent = 'Upload Securely';
7696
+ uploadBtn.onclick = () => this.startUpload(config);
7697
+ div.appendChild(uploadBtn);
7698
+ }
7699
+ // Metadata section
7700
+ const metadata = document.createElement('div');
7701
+ metadata.className = 'svu-metadata';
7702
+ metadata.innerHTML = `
7703
+ <h4>Transfer Details</h4>
7704
+ <div class="svu-metadata-grid">
7705
+ <div class="svu-metadata-item">
7706
+ <span class="svu-metadata-label">Recipient</span>
7707
+ <span class="svu-metadata-value">${this.escapeHtml(config.branding.organizationName)}</span>
7708
+ </div>
7709
+ <div class="svu-metadata-item">
7710
+ <span class="svu-metadata-label">Vault</span>
7711
+ <span class="svu-metadata-value">${this.escapeHtml(config.vaultName)}</span>
7712
+ </div>
7713
+ <div class="svu-metadata-item svu-full-width">
7714
+ <span class="svu-metadata-label">Vault ID</span>
7715
+ <span class="svu-metadata-value">${this.escapeHtml(config.vaultId)}</span>
7716
+ </div>
7717
+ <div class="svu-metadata-item">
7718
+ <span class="svu-metadata-label">Encryption</span>
7719
+ <span class="svu-metadata-value">${this.escapeHtml(config.encryption.algorithm)}</span>
7720
+ </div>
7721
+ <div class="svu-metadata-item">
7722
+ <span class="svu-metadata-label">Key Derivation</span>
7723
+ <span class="svu-metadata-value">${this.escapeHtml(config.encryption.keyDerivation)}</span>
7724
+ </div>
7725
+ <div class="svu-metadata-item">
7726
+ <span class="svu-metadata-label">Post-Quantum</span>
7727
+ <span class="svu-metadata-value">${this.escapeHtml(config.encryption.postQuantum)}</span>
7728
+ </div>
7729
+ <div class="svu-metadata-item">
7730
+ <span class="svu-metadata-label">Forge Status</span>
7731
+ <span class="svu-metadata-value ${config.forgeStatus === 'active' ? 'svu-status-active' : ''}">
7732
+ ${config.forgeStatus === 'active' ? '● Active' : '○ Inactive'}
7733
+ </span>
7734
+ </div>
7735
+ </div>
7736
+ `;
7737
+ div.appendChild(metadata);
7738
+ // Setup paste handler
7739
+ this.setupPasteHandler(config);
7740
+ return div;
7741
+ }
7742
+ renderUploading(state) {
7743
+ const div = document.createElement('div');
7744
+ div.className = 'svu-uploading-view';
7745
+ const displayIngotId = hexToBase64url(state.ingotId.replace('ing_', ''));
7746
+ div.innerHTML = `
7747
+ <h2 class="svu-title">Streaming & Encrypting</h2>
7748
+
7749
+ <div class="svu-stages">
7750
+ <div class="svu-stage svu-active">
7751
+ <svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
7752
+ <rect x="2" y="3" width="20" height="18" rx="2"/>
7753
+ <path d="M2 9h20"/>
7754
+ <circle cx="6" cy="6" r="1" fill="currentColor"/>
7755
+ <circle cx="10" cy="6" r="1" fill="currentColor"/>
7756
+ </svg>
7757
+ <span>Your Browser</span>
7758
+ </div>
7759
+ <div class="svu-stage-arrow svu-active"></div>
7760
+ <div class="svu-stage svu-active">
7761
+ <svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
7762
+ <path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
7763
+ </svg>
7764
+ <span>SparkVault</span>
7765
+ <span class="svu-stage-sub">Encrypting</span>
7766
+ </div>
7767
+ <div class="svu-stage-arrow"></div>
7768
+ <div class="svu-stage">
7769
+ <svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
7770
+ <rect x="3" y="3" width="18" height="18" rx="2"/>
7771
+ <circle cx="12" cy="12" r="3"/>
7772
+ <path d="M12 9V6M12 18v-3M9 12H6M18 12h-3"/>
7773
+ </svg>
7774
+ <span>${this.escapeHtml(this.config?.branding.organizationName || '')}</span>
7775
+ </div>
7776
+ </div>
7777
+
7778
+ <div class="svu-progress">
7779
+ <div class="svu-progress-bar">
7780
+ <div class="svu-progress-fill" style="width: ${state.progress}%"></div>
7781
+ </div>
7782
+ <div class="svu-progress-text">
7783
+ <span>${state.progress}%</span>
7784
+ <span>${formatBytes(state.bytesUploaded)} / ${formatBytes(state.file.size)}</span>
7785
+ </div>
7786
+ </div>
7787
+
7788
+ <div class="svu-forging-info">
7789
+ <p class="svu-forging-line">Forging Ingot ID: <span class="svu-mono">${displayIngotId}</span></p>
7790
+ <p class="svu-forging-line">Secure Transfer Channel: <span class="svu-mono">${state.requestId}</span></p>
7791
+ </div>
7792
+ `;
7793
+ return div;
7794
+ }
7795
+ renderCeremony(state) {
7796
+ const div = document.createElement('div');
7797
+ div.className = 'svu-ceremony-view';
7798
+ const displayIngotId = hexToBase64url(state.ingotId.replace('ing_', ''));
7799
+ let stepsHtml = '';
7800
+ CEREMONY_STEPS.forEach((step, index) => {
7801
+ const isComplete = index < state.step;
7802
+ const isActive = index === state.step;
7803
+ stepsHtml += `
7804
+ <div class="svu-ceremony-step ${isComplete ? 'svu-complete' : ''} ${isActive ? 'svu-active' : ''}">
7805
+ <span class="svu-step-icon">
7806
+ ${isComplete
7807
+ ? '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 6L9 17l-5-5"/></svg>'
7808
+ : isActive
7809
+ ? '<svg class="svu-spin" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2v4M12 18v4M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M2 12h4M18 12h4M4.93 19.07l2.83-2.83M16.24 7.76l2.83-2.83"/></svg>'
7810
+ : ''}
7811
+ </span>
7812
+ <span class="svu-step-text">${step.text}</span>
7813
+ </div>
7814
+ `;
7815
+ });
7816
+ div.innerHTML = `
7817
+ <h2 class="svu-title">Securing Your File</h2>
7818
+
7819
+ <div class="svu-ceremony-steps">
7820
+ ${stepsHtml}
7821
+ </div>
7822
+
7823
+ <div class="svu-forging-info">
7824
+ <p class="svu-forging-line">Forging Ingot ID: <span class="svu-mono">${displayIngotId}</span></p>
7825
+ <p class="svu-forging-line">Secure Transfer Channel: <span class="svu-mono">${state.requestId}</span></p>
7826
+ </div>
7827
+ `;
7828
+ return div;
7829
+ }
7830
+ renderComplete(result, config) {
7831
+ const div = document.createElement('div');
7832
+ div.className = 'svu-complete-view';
7833
+ div.innerHTML = `
7834
+ <div class="svu-success-icon">
7835
+ <svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
7836
+ <path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/>
7837
+ <path d="M22 4L12 14.01l-3-3"/>
7838
+ </svg>
7839
+ </div>
7840
+
7841
+ <h2 class="svu-success-title">Your file has been securely sent!</h2>
7842
+
7843
+ <div class="svu-success-details">
7844
+ <div class="svu-success-row">
7845
+ <span class="svu-success-label">Ingot ID</span>
7846
+ <span class="svu-success-value svu-mono">
7847
+ ${this.escapeHtml(result.ingotId)}
7848
+ <button class="svu-copy-btn" aria-label="Copy Ingot ID">
7849
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
7850
+ <rect x="9" y="9" width="13" height="13" rx="2" ry="2"/>
7851
+ <path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/>
7852
+ </svg>
7853
+ </button>
7854
+ </span>
7855
+ </div>
7856
+ <div class="svu-success-row">
7857
+ <span class="svu-success-label">File</span>
7858
+ <span class="svu-success-value">${this.escapeHtml(result.filename)}</span>
7859
+ </div>
7860
+ <div class="svu-success-row">
7861
+ <span class="svu-success-label">Size</span>
7862
+ <span class="svu-success-value">${formatBytes(result.sizeBytes)}</span>
7863
+ </div>
7864
+ <div class="svu-success-row">
7865
+ <span class="svu-success-label">Timestamp</span>
7866
+ <span class="svu-success-value svu-mono">${this.escapeHtml(result.uploadTime)}</span>
7867
+ </div>
7868
+ <div class="svu-success-row">
7869
+ <span class="svu-success-label">Encryption</span>
7870
+ <span class="svu-success-value">AES-256-GCM</span>
7871
+ </div>
7872
+ </div>
7873
+
7874
+ <p class="svu-success-guidance">
7875
+ ${this.escapeHtml(config.branding.organizationName)} has been notified and can access this file
7876
+ from their SparkVault dashboard. Save the Ingot ID above for your records.
7877
+ </p>
7878
+
7879
+ <button class="svu-btn svu-btn-secondary">
7880
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
7881
+ <path d="M1 4v6h6M23 20v-6h-6"/>
7882
+ <path d="M20.49 9A9 9 0 005.64 5.64L1 10m22 4l-4.64 4.36A9 9 0 013.51 15"/>
7883
+ </svg>
7884
+ Upload Another File
7885
+ </button>
7886
+ `;
7887
+ // Copy button handler
7888
+ const copyBtn = div.querySelector('.svu-copy-btn');
7889
+ if (copyBtn) {
7890
+ copyBtn.addEventListener('click', () => {
7891
+ navigator.clipboard.writeText(result.ingotId);
7892
+ });
7893
+ }
7894
+ // Upload another handler
7895
+ const uploadAnotherBtn = div.querySelector('.svu-btn-secondary');
7896
+ if (uploadAnotherBtn) {
7897
+ uploadAnotherBtn.addEventListener('click', () => {
7898
+ this.selectedFile = null;
7899
+ this.setState({ view: 'form', config });
7900
+ });
7901
+ }
7902
+ return div;
7903
+ }
7904
+ renderError(state) {
7905
+ const div = document.createElement('div');
7906
+ div.className = 'svu-error-view';
7907
+ const statusCode = state.httpStatus || 404;
7908
+ const statusText = statusCode === 402 ? 'Service Unavailable' : 'Resource Not Found';
7909
+ div.innerHTML = `
7910
+ <div class="svu-error-icon">
7911
+ <svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
7912
+ <circle cx="12" cy="12" r="10"/>
7913
+ <path d="M12 8v4M12 16h.01"/>
7914
+ </svg>
7915
+ </div>
7916
+
7917
+ <h2 class="svu-error-title">${statusCode} - ${statusText}</h2>
7918
+
7919
+ <p class="svu-error-message">${this.escapeHtml(state.message)}</p>
7920
+
7921
+ <div class="svu-error-code">
7922
+ <span class="svu-error-code-label">Code</span>
7923
+ <span class="svu-error-code-value">${this.escapeHtml(state.code)}</span>
7924
+ </div>
7925
+ `;
7926
+ return div;
7927
+ }
7928
+ handleFileSelect(file, config) {
7929
+ // Validate file size
7930
+ if (file.size > config.maxSizeBytes) {
7931
+ this.setState({
7932
+ view: 'form',
7933
+ config,
7934
+ error: `File size exceeds maximum allowed (${formatBytes(config.maxSizeBytes)})`,
7935
+ });
7936
+ return;
7937
+ }
7938
+ this.selectedFile = file;
7939
+ this.setState({ view: 'form', config });
7940
+ }
7941
+ openFileSelector(config) {
7942
+ this.cleanupFileInput();
7943
+ this.fileInputElement = document.createElement('input');
7944
+ this.fileInputElement.type = 'file';
7945
+ this.fileInputElement.style.display = 'none';
7946
+ this.fileInputElement.onchange = () => {
7947
+ const file = this.fileInputElement?.files?.[0];
7948
+ if (file) {
7949
+ this.handleFileSelect(file, config);
7950
+ }
7951
+ };
7952
+ document.body.appendChild(this.fileInputElement);
7953
+ this.fileInputElement.click();
7954
+ }
7955
+ cleanupFileInput() {
7956
+ if (this.fileInputElement) {
7957
+ this.fileInputElement.remove();
7958
+ this.fileInputElement = null;
7959
+ }
7960
+ }
7961
+ setupPasteHandler(config) {
7962
+ // Remove any existing handler first
7963
+ this.cleanupPasteHandler();
7964
+ this.pasteHandler = (e) => {
7965
+ const items = e.clipboardData?.items;
7966
+ if (!items)
7967
+ return;
7968
+ for (const item of items) {
7969
+ if (item.kind === 'file') {
7970
+ const file = item.getAsFile();
7971
+ if (file) {
7972
+ this.handleFileSelect(file, config);
7973
+ break;
7974
+ }
7975
+ }
7976
+ }
7977
+ };
7978
+ document.addEventListener('paste', this.pasteHandler);
7979
+ }
7980
+ cleanupPasteHandler() {
7981
+ if (this.pasteHandler) {
7982
+ document.removeEventListener('paste', this.pasteHandler);
7983
+ this.pasteHandler = null;
7984
+ }
7985
+ }
7986
+ async startUpload(config) {
7987
+ if (!this.selectedFile)
7988
+ return;
7989
+ const file = this.selectedFile;
7990
+ const requestId = generateRequestId();
7991
+ try {
7992
+ // Initiate upload
7993
+ const { forgeUrl, ingotId } = await this.api.initiateUpload(config.vaultId, file.name, file.size, file.type || 'application/octet-stream');
7994
+ // Show uploading state
7995
+ this.setState({
7996
+ view: 'uploading',
7997
+ file,
7998
+ ingotId,
7999
+ requestId,
8000
+ progress: 0,
8001
+ bytesUploaded: 0,
8002
+ });
8003
+ // Upload file using tus (resumable upload)
8004
+ await this.uploadWithTus(file, forgeUrl, ingotId, requestId);
8005
+ // Run ceremony animation
8006
+ await this.runCeremony(file, ingotId, requestId);
8007
+ // Complete
8008
+ const now = new Date();
8009
+ const uploadTime = `${now.getUTCFullYear()}-${String(now.getUTCMonth() + 1).padStart(2, '0')}-${String(now.getUTCDate()).padStart(2, '0')} ${String(now.getUTCHours()).padStart(2, '0')}:${String(now.getUTCMinutes()).padStart(2, '0')}:${String(now.getUTCSeconds()).padStart(2, '0')} UTC`;
8010
+ const result = {
8011
+ ingotId,
8012
+ vaultId: config.vaultId,
8013
+ filename: file.name,
8014
+ sizeBytes: file.size,
8015
+ uploadTime,
8016
+ };
8017
+ // Notify complete phase
8018
+ this.callbacks.onProgress?.({
8019
+ bytesUploaded: file.size,
8020
+ bytesTotal: file.size,
8021
+ percentage: 100,
8022
+ phase: 'complete',
8023
+ });
8024
+ this.setState({ view: 'complete', result, config });
8025
+ // Notify success
8026
+ this.callbacks.onSuccess(result);
8027
+ }
8028
+ catch (error) {
8029
+ this.handleApiError(error);
8030
+ }
8031
+ }
8032
+ async uploadWithTus(file, forgeUrl, ingotId, requestId) {
8033
+ // Simple XHR upload with progress (tus-like behavior)
8034
+ // Timeout: 10 minutes for large file uploads
8035
+ const UPLOAD_TIMEOUT_MS = 10 * 60 * 1000;
8036
+ return new Promise((resolve, reject) => {
8037
+ const xhr = new XMLHttpRequest();
8038
+ xhr.timeout = UPLOAD_TIMEOUT_MS;
8039
+ xhr.upload.onprogress = (e) => {
8040
+ if (e.lengthComputable) {
8041
+ const progress = Math.round((e.loaded / e.total) * 100);
8042
+ this.setState({
8043
+ view: 'uploading',
8044
+ file,
8045
+ ingotId,
8046
+ requestId,
8047
+ progress,
8048
+ bytesUploaded: e.loaded,
8049
+ });
8050
+ this.callbacks.onProgress?.({
8051
+ bytesUploaded: e.loaded,
8052
+ bytesTotal: e.total,
8053
+ percentage: progress,
8054
+ phase: 'uploading',
8055
+ });
8056
+ }
8057
+ };
8058
+ xhr.onload = () => {
8059
+ if (xhr.status >= 200 && xhr.status < 300) {
8060
+ resolve();
8061
+ }
8062
+ else {
8063
+ reject(new Error(`Upload failed with status ${xhr.status}`));
8064
+ }
8065
+ };
8066
+ xhr.onerror = () => reject(new Error('Upload failed'));
8067
+ xhr.ontimeout = () => reject(new Error('Upload timed out'));
8068
+ xhr.open('POST', forgeUrl);
8069
+ xhr.setRequestHeader('Content-Type', file.type || 'application/octet-stream');
8070
+ xhr.send(file);
8071
+ });
8072
+ }
8073
+ async runCeremony(file, ingotId, requestId) {
8074
+ for (let i = 0; i < CEREMONY_STEPS.length; i++) {
8075
+ this.setState({
8076
+ view: 'ceremony',
8077
+ file,
8078
+ ingotId,
8079
+ requestId,
8080
+ step: i,
8081
+ complete: false,
8082
+ });
8083
+ this.callbacks.onProgress?.({
8084
+ bytesUploaded: file.size,
8085
+ bytesTotal: file.size,
8086
+ percentage: 100,
8087
+ phase: 'ceremony',
8088
+ });
8089
+ await new Promise(resolve => setTimeout(resolve, CEREMONY_STEPS[i].duration));
8090
+ }
8091
+ // Mark complete
8092
+ this.setState({
8093
+ view: 'ceremony',
8094
+ file,
8095
+ ingotId,
8096
+ requestId,
8097
+ step: CEREMONY_STEPS.length,
8098
+ complete: true,
8099
+ });
8100
+ await new Promise(resolve => setTimeout(resolve, 500));
8101
+ }
8102
+ handleApiError(error) {
8103
+ if (error instanceof UploadApiError) {
8104
+ // Handle specific HTTP status codes
8105
+ if (error.httpStatus === 404) {
8106
+ this.setState({
8107
+ view: 'error',
8108
+ message: 'The requested upload endpoint could not be located. This may occur if the upload link has expired, been disabled, or was entered incorrectly.',
8109
+ code: 'NOT_FOUND',
8110
+ httpStatus: 404,
8111
+ });
8112
+ }
8113
+ else if (error.httpStatus === 402) {
8114
+ this.setState({
8115
+ view: 'error',
8116
+ message: 'This upload endpoint has been temporarily disabled due to an account billing issue. Please contact the organization\'s administrator.',
8117
+ code: 'PAYMENT_REQUIRED',
8118
+ httpStatus: 402,
8119
+ });
8120
+ }
8121
+ else {
8122
+ this.setState({
8123
+ view: 'error',
8124
+ message: error.message,
8125
+ code: error.code,
8126
+ httpStatus: error.httpStatus,
8127
+ });
8128
+ }
8129
+ this.callbacks.onError(error);
8130
+ }
8131
+ else if (error instanceof Error) {
8132
+ this.setState({
8133
+ view: 'error',
8134
+ message: error.message,
8135
+ code: 'UNKNOWN_ERROR',
8136
+ });
8137
+ this.callbacks.onError(error);
8138
+ }
8139
+ else {
8140
+ const err = new Error('An unexpected error occurred');
8141
+ this.setState({
8142
+ view: 'error',
8143
+ message: err.message,
8144
+ code: 'UNKNOWN_ERROR',
8145
+ });
8146
+ this.callbacks.onError(err);
8147
+ }
8148
+ }
8149
+ escapeHtml(str) {
8150
+ const div = document.createElement('div');
8151
+ div.textContent = str;
8152
+ return div.innerHTML;
8153
+ }
8154
+ }
8155
+
8156
+ /* global Element */
5377
8157
  /**
5378
- * Sparks Module
8158
+ * Vault Upload Module
5379
8159
  *
5380
- * Create and read ephemeral encrypted secrets.
5381
- * Sparks are destroyed after being read (burn-on-read).
8160
+ * Embedded upload widget for vaults with public upload enabled.
8161
+ *
8162
+ * @example Dialog mode (immediate)
8163
+ * const result = await sv.vaults.upload({ vaultId: 'vlt_abc123' });
8164
+ *
8165
+ * @example Dialog mode (attached to clicks)
8166
+ * sv.vaults.upload.attach('.upload-btn', { vaultId: 'vlt_abc123' });
8167
+ *
8168
+ * @example Inline mode
8169
+ * const result = await sv.vaults.upload({
8170
+ * vaultId: 'vlt_abc123',
8171
+ * target: '#upload-container'
8172
+ * });
5382
8173
  */
5383
- class SparksModule {
5384
- constructor(http) {
5385
- this.http = http;
8174
+ class VaultUploadModule {
8175
+ constructor(config) {
8176
+ this.renderer = null;
8177
+ this.attachedElements = new Map();
8178
+ this.config = config;
8179
+ this.api = new UploadApi(config);
5386
8180
  }
5387
8181
  /**
5388
- * Create an ephemeral encrypted secret.
8182
+ * Upload a file to a vault.
5389
8183
  *
5390
- * @example
5391
- * const spark = await sv.sparks.create({
5392
- * payload: 'my secret data',
5393
- * ttl: 3600,
5394
- * maxReads: 1
8184
+ * - Without `target`: Opens a dialog (modal)
8185
+ * - With `target`: Renders inline into the specified element
8186
+ *
8187
+ * @example Dialog mode
8188
+ * const result = await sv.vaults.upload({ vaultId: 'vlt_abc123' });
8189
+ * console.log('Uploaded:', result.ingotId);
8190
+ *
8191
+ * @example Inline mode
8192
+ * const result = await sv.vaults.upload({
8193
+ * vaultId: 'vlt_abc123',
8194
+ * target: '#upload-container'
5395
8195
  * });
5396
- * console.log(spark.url);
5397
8196
  */
5398
- async create(options) {
5399
- this.validateCreateOptions(options);
5400
- const payload = this.encodePayload(options.payload);
5401
- const response = await this.http.post('/v1/sparks', {
5402
- payload,
5403
- ttl: options.ttl ?? 3600,
5404
- max_reads: options.maxReads ?? 1,
5405
- password: options.password,
5406
- name: options.name,
5407
- });
5408
- return {
5409
- id: response.data.spark_id,
5410
- url: response.data.url,
5411
- expiresAt: response.data.expires_at,
5412
- maxReads: response.data.max_reads,
5413
- name: response.data.name,
8197
+ async upload(options) {
8198
+ if (!options.vaultId) {
8199
+ throw new ValidationError('vaultId is required');
8200
+ }
8201
+ // Close any existing renderer
8202
+ if (this.renderer) {
8203
+ this.renderer.close();
8204
+ }
8205
+ const isInline = !!options.target;
8206
+ const container = isInline
8207
+ ? this.createInlineContainer(options.target)
8208
+ : new UploadModalContainer();
8209
+ // Merge global config defaults with per-call options
8210
+ const mergedOptions = {
8211
+ ...options,
8212
+ backdropBlur: options.backdropBlur ?? this.config.backdropBlur,
5414
8213
  };
8214
+ return new Promise((resolve, reject) => {
8215
+ this.renderer = new UploadRenderer(container, this.api, mergedOptions, {
8216
+ onSuccess: (result) => {
8217
+ options.onSuccess?.(result);
8218
+ resolve(result);
8219
+ },
8220
+ onError: (error) => {
8221
+ options.onError?.(error);
8222
+ reject(error);
8223
+ },
8224
+ onCancel: () => {
8225
+ const error = new UserCancelledError();
8226
+ options.onCancel?.();
8227
+ reject(error);
8228
+ },
8229
+ onProgress: options.onProgress,
8230
+ });
8231
+ this.renderer.start().catch((error) => {
8232
+ options.onError?.(error);
8233
+ reject(error);
8234
+ });
8235
+ });
5415
8236
  }
5416
8237
  /**
5417
- * Read and burn a spark.
5418
- * The spark is destroyed after the configured number of reads.
8238
+ * Attach upload functionality to element clicks.
8239
+ * When any matching element is clicked, opens the upload dialog.
8240
+ *
8241
+ * @param selector - CSS selector for elements to attach to
8242
+ * @param options - Upload options including vaultId and callbacks
8243
+ * @returns Cleanup function to remove event listeners
5419
8244
  *
5420
8245
  * @example
5421
- * const payload = await sv.sparks.read('spk_abc123');
5422
- * console.log(payload.data);
8246
+ * const cleanup = sv.vaults.upload.attach('.upload-btn', {
8247
+ * vaultId: 'vlt_abc123',
8248
+ * onSuccess: (result) => console.log('Uploaded:', result.ingotId)
8249
+ * });
8250
+ *
8251
+ * // Later, remove listeners
8252
+ * cleanup();
5423
8253
  */
5424
- async read(id, password) {
5425
- if (!id) {
5426
- throw new ValidationError('Spark ID is required');
8254
+ attach(selector, options) {
8255
+ if (!options.vaultId) {
8256
+ throw new ValidationError('vaultId is required');
5427
8257
  }
5428
- const cleanId = id.startsWith('spk_') ? id : `spk_${id}`;
5429
- const headers = {};
5430
- if (password) {
5431
- headers['X-Spark-Password'] = password;
5432
- }
5433
- const response = await this.http.get(`/v1/sparks/${cleanId}`, { headers });
5434
- return {
5435
- data: this.decodePayload(response.data.payload),
5436
- readAt: response.data.read_at,
5437
- burned: response.data.burned,
5438
- readsRemaining: response.data.reads_remaining,
8258
+ let observer = null;
8259
+ const handleClick = async (event) => {
8260
+ event.preventDefault();
8261
+ try {
8262
+ await this.upload({
8263
+ vaultId: options.vaultId,
8264
+ onSuccess: options.onSuccess,
8265
+ onError: options.onError,
8266
+ onCancel: options.onCancel,
8267
+ onProgress: options.onProgress,
8268
+ });
8269
+ }
8270
+ catch {
8271
+ // Error already handled by callbacks or will use server redirect
8272
+ }
8273
+ };
8274
+ const attachToElements = () => {
8275
+ const elements = document.querySelectorAll(selector);
8276
+ elements.forEach((element) => {
8277
+ element.addEventListener('click', handleClick);
8278
+ this.attachedElements.set(element, () => {
8279
+ element.removeEventListener('click', handleClick);
8280
+ });
8281
+ });
8282
+ // Watch for dynamically added elements
8283
+ observer = new MutationObserver((mutations) => {
8284
+ mutations.forEach((mutation) => {
8285
+ mutation.addedNodes.forEach((node) => {
8286
+ if (node instanceof Element) {
8287
+ if (node.matches(selector)) {
8288
+ node.addEventListener('click', handleClick);
8289
+ this.attachedElements.set(node, () => {
8290
+ node.removeEventListener('click', handleClick);
8291
+ });
8292
+ }
8293
+ // Check descendants
8294
+ node.querySelectorAll(selector).forEach((el) => {
8295
+ if (!this.attachedElements.has(el)) {
8296
+ el.addEventListener('click', handleClick);
8297
+ this.attachedElements.set(el, () => {
8298
+ el.removeEventListener('click', handleClick);
8299
+ });
8300
+ }
8301
+ });
8302
+ }
8303
+ });
8304
+ });
8305
+ });
8306
+ observer.observe(document.body, { childList: true, subtree: true });
8307
+ };
8308
+ // Defer attachment until DOM is ready
8309
+ onDomReady(attachToElements);
8310
+ // Return cleanup function
8311
+ return () => {
8312
+ observer?.disconnect();
8313
+ this.attachedElements.forEach((cleanup) => cleanup());
8314
+ this.attachedElements.clear();
5439
8315
  };
5440
8316
  }
5441
- validateCreateOptions(options) {
5442
- if (!options.payload) {
5443
- throw new ValidationError('Payload is required');
8317
+ /**
8318
+ * Close the upload dialog/inline UI if open.
8319
+ */
8320
+ close() {
8321
+ if (this.renderer) {
8322
+ this.renderer.close();
8323
+ this.renderer = null;
5444
8324
  }
5445
- if (options.ttl !== undefined && (options.ttl < 60 || options.ttl > 604800)) {
5446
- throw new ValidationError('TTL must be between 60 and 604800 seconds (1 minute to 7 days)');
8325
+ }
8326
+ /**
8327
+ * Resolve target to an HTMLElement and create inline container.
8328
+ */
8329
+ createInlineContainer(target) {
8330
+ let element;
8331
+ if (typeof target === 'string') {
8332
+ const found = document.querySelector(target);
8333
+ if (!found || !(found instanceof HTMLElement)) {
8334
+ throw new ValidationError(`Target selector "${target}" did not match any element`);
8335
+ }
8336
+ element = found;
5447
8337
  }
5448
- if (options.maxReads !== undefined && (options.maxReads < 1 || options.maxReads > 100)) {
5449
- throw new ValidationError('maxReads must be between 1 and 100');
8338
+ else if (target instanceof HTMLElement) {
8339
+ element = target;
5450
8340
  }
5451
- }
5452
- encodePayload(payload) {
5453
- if (typeof payload === 'string') {
5454
- return payload;
8341
+ else {
8342
+ throw new ValidationError('Target must be a CSS selector string or HTMLElement');
5455
8343
  }
5456
- // Use centralized base64url utility (CLAUDE.md DRY)
5457
- return base64urlEncode(payload);
8344
+ return new UploadInlineContainer(element);
5458
8345
  }
5459
- decodePayload(payload) {
5460
- return payload;
8346
+ // Deprecated methods for backwards compatibility
8347
+ /**
8348
+ * @deprecated Use `upload()` instead. Will be removed in v2.0.
8349
+ */
8350
+ async pop(options) {
8351
+ return this.upload(options);
8352
+ }
8353
+ /**
8354
+ * @deprecated Use `upload({ target })` instead. Will be removed in v2.0.
8355
+ */
8356
+ async render(options) {
8357
+ return this.upload(options);
5461
8358
  }
5462
8359
  }
5463
8360
 
@@ -5468,9 +8365,14 @@ class SparksModule {
5468
8365
  * Uses Triple Zero-Trust encryption (SVMK + AMK + VMK).
5469
8366
  */
5470
8367
  class VaultsModule {
5471
- constructor(_config, http) {
5472
- // Config parameter kept for API consistency; may be used for versioning/feature flags
8368
+ constructor(config, http) {
5473
8369
  this.http = http;
8370
+ this.uploadModule = new VaultUploadModule(config);
8371
+ // Create callable that also has attach/close methods
8372
+ const uploadFn = ((options) => this.uploadModule.upload(options));
8373
+ uploadFn.attach = (selector, options) => this.uploadModule.attach(selector, options);
8374
+ uploadFn.close = () => this.uploadModule.close();
8375
+ this.upload = uploadFn;
5474
8376
  }
5475
8377
  /**
5476
8378
  * Create a new encrypted vault.
@@ -5644,119 +8546,6 @@ class VaultsModule {
5644
8546
  }
5645
8547
  }
5646
8548
 
5647
- /**
5648
- * RNG Module
5649
- *
5650
- * Generate cryptographically secure random numbers.
5651
- * Uses hybrid entropy from AWS KMS HSM + local CSPRNG.
5652
- */
5653
- const VALID_RNG_FORMATS = [
5654
- 'hex', 'base64', 'base64url', 'alphanumeric',
5655
- 'alphanumeric-mixed', 'password', 'numeric', 'uuid', 'bytes'
5656
- ];
5657
- function isRNGFormat(value) {
5658
- return VALID_RNG_FORMATS.includes(value);
5659
- }
5660
- class RNGModule {
5661
- constructor(http) {
5662
- this.http = http;
5663
- }
5664
- /**
5665
- * Generate cryptographically secure random bytes.
5666
- *
5667
- * @example
5668
- * const result = await sv.rng.generate({ bytes: 32, format: 'hex' });
5669
- * console.log(result.value);
5670
- */
5671
- async generate(options) {
5672
- this.validateOptions(options);
5673
- const response = await this.http.post('/v1/apps/rng/generate', {
5674
- num_bytes: options.bytes,
5675
- format: options.format ?? 'base64url',
5676
- });
5677
- const responseFormat = response.data.format ?? 'base64url';
5678
- if (!isRNGFormat(responseFormat)) {
5679
- throw new ValidationError(`Server returned invalid format: ${responseFormat}`);
5680
- }
5681
- return {
5682
- // Use centralized base64url utility (CLAUDE.md DRY)
5683
- value: responseFormat === 'bytes'
5684
- ? base64urlToBytes(response.data.random_bytes_b64)
5685
- : response.data.random_bytes_b64,
5686
- bytes: response.data.num_bytes,
5687
- format: responseFormat,
5688
- referenceId: response.data.reference_id,
5689
- };
5690
- }
5691
- /**
5692
- * Generate a random UUID v4.
5693
- *
5694
- * @example
5695
- * const uuid = await sv.rng.uuid();
5696
- * console.log(uuid); // 'f47ac10b-58cc-4372-a567-0e02b2c3d479'
5697
- */
5698
- async uuid() {
5699
- const result = await this.generate({ bytes: 16, format: 'uuid' });
5700
- if (typeof result.value !== 'string') {
5701
- throw new ValidationError('Expected string value for uuid format');
5702
- }
5703
- return result.value;
5704
- }
5705
- /**
5706
- * Generate a random hex string.
5707
- *
5708
- * @example
5709
- * const hex = await sv.rng.hex(16);
5710
- * console.log(hex); // '1a2b3c4d5e6f7890...'
5711
- */
5712
- async hex(bytes) {
5713
- const result = await this.generate({ bytes, format: 'hex' });
5714
- if (typeof result.value !== 'string') {
5715
- throw new ValidationError('Expected string value for hex format');
5716
- }
5717
- return result.value;
5718
- }
5719
- /**
5720
- * Generate a random alphanumeric string.
5721
- *
5722
- * @example
5723
- * const code = await sv.rng.alphanumeric(8);
5724
- * console.log(code); // 'A1B2C3D4'
5725
- */
5726
- async alphanumeric(bytes) {
5727
- const result = await this.generate({ bytes, format: 'alphanumeric-mixed' });
5728
- if (typeof result.value !== 'string') {
5729
- throw new ValidationError('Expected string value for alphanumeric format');
5730
- }
5731
- return result.value;
5732
- }
5733
- /**
5734
- * Generate a random password with special characters.
5735
- *
5736
- * @example
5737
- * const password = await sv.rng.password(16);
5738
- * console.log(password); // 'aB3$xY7!mN9@pQ2#'
5739
- */
5740
- async password(bytes) {
5741
- const result = await this.generate({ bytes, format: 'password' });
5742
- if (typeof result.value !== 'string') {
5743
- throw new ValidationError('Expected string value for password format');
5744
- }
5745
- return result.value;
5746
- }
5747
- validateOptions(options) {
5748
- if (!options.bytes || typeof options.bytes !== 'number') {
5749
- throw new ValidationError('bytes is required and must be a number');
5750
- }
5751
- if (options.bytes < 1 || options.bytes > 1024) {
5752
- throw new ValidationError('bytes must be between 1 and 1024');
5753
- }
5754
- if (options.format && !isRNGFormat(options.format)) {
5755
- throw new ValidationError(`Invalid format. Must be one of: ${VALID_RNG_FORMATS.join(', ')}`);
5756
- }
5757
- }
5758
- }
5759
-
5760
8549
  /**
5761
8550
  * SparkVault Debug Logger
5762
8551
  *
@@ -5844,412 +8633,95 @@ const logger = {
5844
8633
  /**
5845
8634
  * SparkVault Auto-Initialization
5846
8635
  *
5847
- * Enables zero-config initialization via script tag data attributes.
8636
+ * Auto-initializes the SDK from script tag data attributes.
5848
8637
  *
5849
8638
  * @example
5850
8639
  * ```html
5851
8640
  * <script
5852
- * async
5853
8641
  * src="https://cdn.sparkvault.com/sdk/v1/sparkvault.js"
5854
8642
  * data-account-id="acc_your_account_id"
5855
- * data-attach-selector=".js-sparkvault-auth"
5856
- * data-success-url="https://example.com/auth/verify-token"
5857
- * data-error-function="handleSparkVaultError"
5858
- * data-debug="true"
5859
8643
  * ></script>
8644
+ *
8645
+ * <script>
8646
+ * // SDK is ready - just use it
8647
+ * SparkVault.identity.attach('.login-btn', {
8648
+ * onSuccess: (result) => console.log('Verified:', result.identity)
8649
+ * });
8650
+ * </script>
5860
8651
  * ```
5861
8652
  *
5862
8653
  * Supported attributes:
5863
8654
  * - data-account-id: Account ID (required for auto-init)
5864
- * - data-attach-selector: CSS selector for elements to attach click handlers
5865
- * - data-success-url: URL to POST { token, identity } on successful verification
5866
- * - data-success-function: Global function name to call on success (receives { token, identity, identityType })
5867
- * - data-error-url: URL to redirect to on error (appends ?error=message)
5868
- * - data-error-function: Global function name to call on error (receives Error object)
5869
8655
  * - data-debug: Set to "true" to enable verbose console logging
5870
8656
  */
5871
- const state = {
5872
- initialized: false,
5873
- instance: null,
5874
- config: null,
5875
- observer: null,
5876
- isAuthenticating: false,
5877
- };
5878
- /**
5879
- * Get the current script element
5880
- */
5881
- function getCurrentScript() {
5882
- // Try document.currentScript first (works during initial execution)
5883
- if (document.currentScript instanceof HTMLScriptElement) {
5884
- return document.currentScript;
5885
- }
5886
- // Fallback: find script by src pattern
5887
- const scripts = document.querySelectorAll('script[src*="sparkvault"]');
5888
- for (const script of scripts) {
5889
- if (script.dataset.accountId) {
5890
- return script;
5891
- }
5892
- }
5893
- return scripts[0] || null;
5894
- }
5895
- /**
5896
- * Parse configuration from script tag data attributes
5897
- */
5898
- function parseConfig(script) {
5899
- const timeoutStr = script.dataset.timeout;
5900
- return {
5901
- accountId: script.dataset.accountId || null,
5902
- attachSelector: script.dataset.attachSelector || null,
5903
- successUrl: script.dataset.successUrl || null,
5904
- successFunction: script.dataset.successFunction || null,
5905
- errorUrl: script.dataset.errorUrl || null,
5906
- errorFunction: script.dataset.errorFunction || null,
5907
- debug: script.dataset.debug === 'true',
5908
- preloadConfig: script.dataset.preloadConfig !== 'false', // default: true
5909
- timeout: timeoutStr ? parseInt(timeoutStr, 10) : 30000,
5910
- };
5911
- }
5912
- /**
5913
- * Safely resolve a global function by name (no eval)
5914
- */
5915
- function resolveFunction(name) {
5916
- if (!name || typeof name !== 'string') {
5917
- return null;
5918
- }
5919
- // Split by dots for nested properties (e.g., "MyApp.auth.onSuccess")
5920
- const parts = name.split('.');
5921
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
5922
- let current = window;
5923
- for (const part of parts) {
5924
- if (current && typeof current === 'object' && part in current) {
5925
- current = current[part];
5926
- }
5927
- else {
5928
- logger.warn(`Function "${name}" not found on window`);
5929
- return null;
5930
- }
5931
- }
5932
- if (typeof current === 'function') {
5933
- return current;
5934
- }
5935
- logger.warn(`"${name}" is not a function`);
5936
- return null;
5937
- }
5938
- /**
5939
- * Handle successful verification
5940
- */
5941
- async function handleSuccess(result) {
5942
- const config = state.config;
5943
- if (!config)
5944
- return;
5945
- logger.info('Verification successful', {
5946
- identity: result.identity,
5947
- identityType: result.identityType,
5948
- hasToken: !!result.token,
5949
- });
5950
- // Call success function if specified
5951
- if (config.successFunction) {
5952
- const fn = resolveFunction(config.successFunction);
5953
- if (fn) {
5954
- logger.debug(`Calling success function: ${config.successFunction}`);
5955
- try {
5956
- fn(result);
5957
- }
5958
- catch (err) {
5959
- logger.error('Error in success function:', err);
5960
- }
5961
- }
5962
- }
5963
- // POST to success URL if specified
5964
- if (config.successUrl) {
5965
- logger.debug(`POSTing to success URL: ${config.successUrl}`);
5966
- try {
5967
- const response = await fetch(config.successUrl, {
5968
- method: 'POST',
5969
- headers: {
5970
- 'Content-Type': 'application/json',
5971
- 'Accept': 'application/json',
5972
- },
5973
- credentials: 'include',
5974
- body: JSON.stringify({
5975
- token: result.token,
5976
- identity: result.identity,
5977
- identityType: result.identityType,
5978
- }),
5979
- });
5980
- const data = await response.json();
5981
- logger.debug('Success URL response:', data);
5982
- // If server returns a redirect URL, follow it
5983
- if (data.redirectUrl || data.redirect_url) {
5984
- const redirectUrl = data.redirectUrl || data.redirect_url;
5985
- logger.info(`Redirecting to: ${redirectUrl}`);
5986
- window.location.href = redirectUrl;
5987
- }
5988
- else if (data.reload === true) {
5989
- logger.info('Reloading page');
5990
- window.location.reload();
5991
- }
5992
- }
5993
- catch (err) {
5994
- logger.error('Failed to POST to success URL:', err);
5995
- handleError(err instanceof Error ? err : new Error(String(err)));
5996
- }
5997
- }
5998
- }
5999
- /**
6000
- * Handle verification error
6001
- */
6002
- function handleError(error) {
6003
- const config = state.config;
6004
- if (!config)
6005
- return;
6006
- logger.error('Verification error:', error.message);
6007
- // Call error function if specified
6008
- if (config.errorFunction) {
6009
- const fn = resolveFunction(config.errorFunction);
6010
- if (fn) {
6011
- logger.debug(`Calling error function: ${config.errorFunction}`);
6012
- try {
6013
- fn(error);
6014
- }
6015
- catch (err) {
6016
- logger.error('Error in error function:', err);
6017
- }
6018
- }
6019
- }
6020
- // Redirect to error URL if specified
6021
- if (config.errorUrl) {
6022
- const url = new URL(config.errorUrl, window.location.origin);
6023
- url.searchParams.set('error', error.message);
6024
- logger.debug(`Redirecting to error URL: ${url.toString()}`);
6025
- window.location.href = url.toString();
6026
- }
6027
- }
6028
- /**
6029
- * Start the verification flow (popup modal with configured handlers)
6030
- */
6031
- async function pop() {
6032
- if (!state.instance) {
6033
- logger.error('SDK not initialized');
6034
- return;
6035
- }
6036
- if (state.isAuthenticating) {
6037
- logger.debug('Already authenticating, skipping');
6038
- return;
6039
- }
6040
- state.isAuthenticating = true;
6041
- logger.info('Starting verification...');
6042
- try {
6043
- const result = await state.instance.identity.pop();
6044
- // User cancelled
6045
- if (!result) {
6046
- logger.info('User cancelled verification');
6047
- state.isAuthenticating = false;
6048
- return;
6049
- }
6050
- await handleSuccess(result);
6051
- }
6052
- catch (err) {
6053
- // UserCancelledError is not an error
6054
- if (err && typeof err === 'object' && 'name' in err) {
6055
- const error = err;
6056
- if (error.name === 'UserCancelledError' || error.code === 'user_cancelled') {
6057
- logger.info('User cancelled verification');
6058
- state.isAuthenticating = false;
6059
- return;
6060
- }
6061
- }
6062
- handleError(err instanceof Error ? err : new Error(String(err)));
6063
- }
6064
- finally {
6065
- state.isAuthenticating = false;
6066
- }
6067
- }
6068
- /**
6069
- * Attach click handlers to elements matching the selector
6070
- */
6071
- function attachHandlers() {
6072
- const config = state.config;
6073
- if (!config?.attachSelector)
6074
- return;
6075
- const elements = document.querySelectorAll(config.attachSelector);
6076
- logger.debug(`Found ${elements.length} elements matching "${config.attachSelector}"`);
6077
- elements.forEach((el) => {
6078
- // Skip if already attached
6079
- if (el.dataset.sparkvaultAttached === 'true')
6080
- return;
6081
- el.dataset.sparkvaultAttached = 'true';
6082
- logger.debug('Attaching click handler to element:', el);
6083
- el.addEventListener('click', (e) => {
6084
- logger.debug('Auth element clicked');
6085
- e.preventDefault();
6086
- e.stopPropagation();
6087
- pop();
6088
- });
6089
- });
6090
- }
6091
- /**
6092
- * Start observing for dynamically added elements
6093
- */
6094
- function startObserver() {
6095
- const config = state.config;
6096
- if (!config?.attachSelector)
6097
- return;
6098
- if (typeof MutationObserver === 'undefined') {
6099
- logger.warn('MutationObserver not available, dynamic elements will not be auto-attached');
6100
- return;
6101
- }
6102
- state.observer = new MutationObserver((mutations) => {
6103
- let shouldAttach = false;
6104
- for (const mutation of mutations) {
6105
- if (mutation.addedNodes.length > 0) {
6106
- shouldAttach = true;
6107
- break;
6108
- }
6109
- }
6110
- if (shouldAttach) {
6111
- attachHandlers();
6112
- }
6113
- });
6114
- state.observer.observe(document.body, {
6115
- childList: true,
6116
- subtree: true,
6117
- });
6118
- logger.debug('MutationObserver started for dynamic elements');
6119
- }
6120
8657
  /**
6121
- * Initialize the SDK from script tag attributes
8658
+ * Initialize the SDK from script tag attributes.
6122
8659
  *
6123
8660
  * Called automatically when the script loads.
6124
- * Requires SparkVault class to be passed in to avoid circular dependency.
8661
+ * Returns the initialized instance if data-account-id is present, null otherwise.
6125
8662
  */
6126
8663
  function autoInit(SparkVaultClass) {
6127
- // Prevent double initialization
6128
- if (state.initialized) {
6129
- logger.debug('Auto-init already completed');
6130
- return;
6131
- }
6132
8664
  // Only run in browser
6133
8665
  if (typeof window === 'undefined' || typeof document === 'undefined') {
6134
- return;
8666
+ return null;
6135
8667
  }
6136
- const script = getCurrentScript();
8668
+ // Get the current script element
8669
+ const script = document.currentScript;
6137
8670
  if (!script) {
6138
- // No script tag found, but that's okay - manual init is supported
6139
- return;
8671
+ return null;
6140
8672
  }
6141
- const config = parseConfig(script);
6142
- state.config = config;
6143
- // Enable debug mode first so subsequent logs work
6144
- if (config.debug) {
8673
+ const accountId = script.dataset.accountId;
8674
+ const debug = script.dataset.debug;
8675
+ // Enable debug mode if requested
8676
+ if (debug === 'true') {
6145
8677
  setDebugMode(true);
6146
8678
  }
6147
- logger.info('Auto-init starting...');
6148
- logger.group('Configuration');
6149
- logger.debug('Account ID:', config.accountId || '(not set)');
6150
- logger.debug('Attach Selector:', config.attachSelector || '(not set)');
6151
- logger.debug('Success URL:', config.successUrl || '(not set)');
6152
- logger.debug('Success Function:', config.successFunction || '(not set)');
6153
- logger.debug('Error URL:', config.errorUrl || '(not set)');
6154
- logger.debug('Error Function:', config.errorFunction || '(not set)');
6155
- logger.debug('Debug:', config.debug);
6156
- logger.groupEnd();
6157
- // No account ID = no auto-init (manual init required)
6158
- if (!config.accountId) {
8679
+ // No account ID = no auto-init
8680
+ if (!accountId) {
6159
8681
  logger.debug('No data-account-id attribute, skipping auto-init');
6160
- return;
8682
+ return null;
6161
8683
  }
6162
- // Initialize the SDK
8684
+ logger.info('Auto-initializing SDK...');
6163
8685
  try {
6164
- state.instance = SparkVaultClass.init({
6165
- accountId: config.accountId,
6166
- preloadConfig: config.preloadConfig,
6167
- timeout: config.timeout,
6168
- });
6169
- state.initialized = true;
8686
+ const instance = SparkVaultClass.init({ accountId });
6170
8687
  logger.info('SDK initialized successfully');
8688
+ return instance;
6171
8689
  }
6172
8690
  catch (err) {
6173
8691
  logger.error('Failed to initialize SDK:', err);
6174
- return;
6175
- }
6176
- // Attach handlers if selector specified
6177
- if (config.attachSelector) {
6178
- // Wait for DOM to be ready
6179
- if (document.readyState === 'loading') {
6180
- document.addEventListener('DOMContentLoaded', () => {
6181
- attachHandlers();
6182
- startObserver();
6183
- });
6184
- }
6185
- else {
6186
- attachHandlers();
6187
- startObserver();
6188
- }
6189
- }
6190
- // Expose utility methods on SparkVault global for manual use
6191
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
6192
- const SparkVaultGlobal = window.SparkVault;
6193
- if (SparkVaultGlobal) {
6194
- // Add identity namespace for manual trigger
6195
- SparkVaultGlobal.identity = {
6196
- /** Open popup and use configured handlers (data-success-url, etc.) */
6197
- pop,
6198
- /** Render inline (requires target element, returns result) */
6199
- render: (options) => {
6200
- if (!state.instance) {
6201
- return Promise.reject(new Error('SDK not initialized'));
6202
- }
6203
- return state.instance.identity.render(options);
6204
- },
6205
- /** @deprecated Use pop() instead */
6206
- authenticate: pop,
6207
- /** @deprecated Use pop() instead */
6208
- verify: (options) => {
6209
- if (!state.instance) {
6210
- return Promise.reject(new Error('SDK not initialized'));
6211
- }
6212
- return state.instance.identity.pop(options);
6213
- },
6214
- };
6215
- // For advanced users who need full SDK instance
6216
- SparkVaultGlobal.getInstance = () => state.instance;
8692
+ return null;
6217
8693
  }
6218
- logger.info('Auto-init complete');
6219
8694
  }
6220
8695
 
6221
8696
  /**
6222
8697
  * SparkVault JavaScript SDK
6223
8698
  *
6224
- * A unified SDK for Identity, Sparks, Vaults, and RNG.
8699
+ * A unified SDK for Identity and Vaults.
6225
8700
  *
6226
- * @example Auto-Init (Zero-Config)
8701
+ * @example CDN Usage (Recommended)
6227
8702
  * ```html
6228
8703
  * <script
6229
- * async
6230
8704
  * src="https://cdn.sparkvault.com/sdk/v1/sparkvault.js"
6231
8705
  * data-account-id="acc_your_account"
6232
- * data-attach-selector=".js-sparkvault-auth"
6233
- * data-success-url="https://example.com/auth/verify-token"
6234
- * data-debug="true"
6235
8706
  * ></script>
6236
8707
  *
6237
- * <button class="js-sparkvault-auth">Login with SparkVault</button>
6238
- * ```
6239
- *
6240
- * @example Manual Init
6241
- * ```html
6242
- * <script src="https://cdn.sparkvault.com/sdk/v1/sparkvault.js"></script>
6243
8708
  * <script>
6244
- * const sv = SparkVault.init({
6245
- * accountId: 'acc_your_account'
8709
+ * // SDK auto-initializes. Use JavaScript API:
8710
+ * SparkVault.identity.attach('.login-btn', {
8711
+ * onSuccess: (result) => console.log('Verified:', result.identity)
6246
8712
  * });
6247
- *
6248
- * // Open identity verification modal
6249
- * const result = await sv.identity.pop();
6250
- * console.log(result.identity, result.identityType);
6251
8713
  * </script>
6252
8714
  * ```
8715
+ *
8716
+ * @example npm/Bundler Usage
8717
+ * ```typescript
8718
+ * import SparkVault from '@sparkvault/sdk';
8719
+ *
8720
+ * const sv = SparkVault.init({ accountId: 'acc_your_account' });
8721
+ *
8722
+ * const result = await sv.identity.verify();
8723
+ * console.log(result.identity, result.identityType);
8724
+ * ```
6253
8725
  */
6254
8726
  class SparkVault {
6255
8727
  constructor(config) {
@@ -6257,9 +8729,7 @@ class SparkVault {
6257
8729
  this.config = resolveConfig(config);
6258
8730
  const http = new HttpClient(this.config);
6259
8731
  this.identity = new IdentityModule(this.config);
6260
- this.sparks = new SparksModule(http);
6261
8732
  this.vaults = new VaultsModule(this.config, http);
6262
- this.rng = new RNGModule(http);
6263
8733
  }
6264
8734
  /**
6265
8735
  * Initialize the SparkVault SDK.
@@ -6281,9 +8751,11 @@ class SparkVault {
6281
8751
  }
6282
8752
  }
6283
8753
  if (typeof window !== 'undefined') {
6284
- window.SparkVault = SparkVault;
6285
- // Auto-initialize from script tag attributes
6286
- autoInit(SparkVault);
8754
+ // Auto-initialize from script tag data-account-id attribute
8755
+ const instance = autoInit(SparkVault);
8756
+ // Expose instance (if auto-init succeeded) or class (for manual init)
8757
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
8758
+ window.SparkVault = instance ?? SparkVault;
6287
8759
  }
6288
8760
 
6289
8761
  exports.AuthenticationError = AuthenticationError;