@sparkvault/sdk 1.1.6 → 1.8.2

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