@truly-you/trulyyou-web-sdk 0.1.0

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.
@@ -0,0 +1,1805 @@
1
+ import { TrulyYouSDKConfig, FetchOptions, SigningResult, FetchResult } from '../types'
2
+ import Pusher from 'pusher-js'
3
+
4
+ export class TrulyYouSDK {
5
+ private config: TrulyYouSDKConfig
6
+ private frontendUrl: string
7
+ private apiUrl: string
8
+ private authAppId: string | undefined
9
+ private invisible: boolean
10
+ private targetElement: string | HTMLElement | undefined
11
+ private brandingCache: { primary?: string; background?: string; secondary?: string; textColor?: string; icon?: string; name?: string } | null = null
12
+ private realtimeUrl: string
13
+ private mockMobileDevice: boolean
14
+
15
+ constructor(config: TrulyYouSDKConfig) {
16
+ this.config = config
17
+ this.frontendUrl = config.frontendUrl || this.getDefaultFrontendUrl()
18
+ this.apiUrl = config.apiUrl || this.getDefaultApiUrl()
19
+ this.authAppId = config.authAppId
20
+ this.invisible = config.invisible || false
21
+ this.targetElement = config.targetElement
22
+ this.mockMobileDevice = config.mockMobileDevice || false
23
+
24
+ // Determine realtime WebSocket URL
25
+ if (config.realtimeUrl) {
26
+ this.realtimeUrl = config.realtimeUrl
27
+ console.log('[SDK]: Using provided realtimeUrl:', this.realtimeUrl)
28
+ } else {
29
+ // Infer from frontendUrl - replace http/https with ws/wss
30
+ const url = new URL(this.frontendUrl)
31
+ const wsProtocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
32
+ this.realtimeUrl = `${wsProtocol}//${url.host}`
33
+ console.log('[SDK]: No realtimeUrl provided, inferring from frontendUrl:', this.realtimeUrl)
34
+ }
35
+
36
+ // Fetch branding on instantiation if authAppId is provided
37
+ if (this.authAppId) {
38
+ this.fetchBranding().catch(err => {
39
+ console.warn('[SDK]: Failed to fetch branding:', err)
40
+ })
41
+ }
42
+ }
43
+
44
+
45
+ /**
46
+ * Fetch app branding from SDK backend
47
+ */
48
+ private async fetchBranding(): Promise<void> {
49
+ if (!this.authAppId) return
50
+
51
+ try {
52
+ // Call SDK backend API directly
53
+ const response = await fetch(`${this.apiUrl}/api/apps/${this.authAppId}`, {
54
+ method: 'GET',
55
+ headers: { 'Content-Type': 'application/json' }
56
+ })
57
+ if (response.ok) {
58
+ const data = await response.json()
59
+ // Handle both formats: { app: {...} } or direct app object
60
+ const app = data.app || data
61
+ if (app?.colors || app?.icon || app?.name) {
62
+ this.brandingCache = {
63
+ primary: app.colors?.primary,
64
+ background: app.colors?.background,
65
+ secondary: app.colors?.secondary,
66
+ textColor: app.colors?.textColor,
67
+ icon: app.icon,
68
+ name: app.name
69
+ }
70
+ console.log('[SDK]: Branding fetched and cached:', this.brandingCache)
71
+ }
72
+ }
73
+ } catch (error) {
74
+ console.warn('[SDK]: Error fetching branding:', error)
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Generate QR code with app icon overlaid in center
80
+ */
81
+ private async generateQRCodeWithIcon(qrData: string, imgElement: HTMLImageElement): Promise<void> {
82
+ return new Promise((resolve, reject) => {
83
+ // Step 1: Generate base QR code
84
+ const qrCodeUrl = `https://api.qrserver.com/v1/create-qr-code/?size=300x300&data=${encodeURIComponent(qrData)}`
85
+ const qrImg = new Image()
86
+ qrImg.crossOrigin = 'anonymous'
87
+
88
+ qrImg.onload = () => {
89
+ // Step 2: Create canvas and draw QR code
90
+ const canvas = document.createElement('canvas')
91
+ canvas.width = 300
92
+ canvas.height = 300
93
+ const ctx = canvas.getContext('2d')
94
+
95
+ if (!ctx) {
96
+ reject(new Error('Failed to get canvas context'))
97
+ return
98
+ }
99
+
100
+ // Draw QR code
101
+ ctx.drawImage(qrImg, 0, 0, 300, 300)
102
+
103
+ // Step 3: Overlay app icon if available
104
+ if (this.brandingCache?.icon) {
105
+ // Try to load icon via proxy first (avoids CORS issues)
106
+ // If proxy fails, try direct load, then fallback to QR without icon
107
+ const tryLoadIcon = async (iconUrl: string, useProxy: boolean) => {
108
+ try {
109
+ let imageUrl = iconUrl
110
+
111
+ if (useProxy) {
112
+ // Fetch via proxy to avoid CORS
113
+ const proxyUrl = `${this.apiUrl}/api/proxy-icon?url=${encodeURIComponent(iconUrl)}`
114
+ const response = await fetch(proxyUrl)
115
+ if (!response.ok) {
116
+ throw new Error('Proxy fetch failed')
117
+ }
118
+ const blob = await response.blob()
119
+ imageUrl = URL.createObjectURL(blob)
120
+ }
121
+
122
+ const iconImg = new Image()
123
+ iconImg.crossOrigin = useProxy ? null : 'anonymous'
124
+
125
+ await new Promise<void>((resolveIcon, rejectIcon) => {
126
+ iconImg.onload = () => resolveIcon()
127
+ iconImg.onerror = () => rejectIcon(new Error('Image load failed'))
128
+ iconImg.src = imageUrl
129
+ })
130
+
131
+ // Calculate icon size (20% of QR code size)
132
+ const iconSize = 60
133
+ const iconX = (300 - iconSize) / 2
134
+ const iconY = (300 - iconSize) / 2
135
+
136
+ // Draw white background circle for icon
137
+ ctx.fillStyle = '#ffffff'
138
+ ctx.beginPath()
139
+ ctx.arc(150, 150, iconSize / 2 + 4, 0, 2 * Math.PI)
140
+ ctx.fill()
141
+
142
+ // Draw icon
143
+ ctx.drawImage(iconImg, iconX, iconY, iconSize, iconSize)
144
+
145
+ // Clean up object URL if we created one
146
+ if (useProxy && imageUrl.startsWith('blob:')) {
147
+ URL.revokeObjectURL(imageUrl)
148
+ }
149
+
150
+ // Convert canvas to data URL and set as image source
151
+ imgElement.src = canvas.toDataURL('image/png')
152
+ resolve()
153
+ } catch (error) {
154
+ throw error
155
+ }
156
+ }
157
+
158
+ // Try proxy first, then direct, then fallback
159
+ const iconUrl = this.brandingCache.icon
160
+ tryLoadIcon(iconUrl, true).catch(() => {
161
+ // If proxy fails, try direct load
162
+ tryLoadIcon(iconUrl, false).catch(() => {
163
+ // If both fail, just use QR code without icon
164
+ imgElement.src = canvas.toDataURL('image/png')
165
+ resolve()
166
+ })
167
+ })
168
+ } else {
169
+ // No icon, just use QR code
170
+ imgElement.src = canvas.toDataURL('image/png')
171
+ resolve()
172
+ }
173
+ }
174
+
175
+ qrImg.onerror = () => {
176
+ reject(new Error('Failed to load QR code'))
177
+ }
178
+
179
+ qrImg.src = qrCodeUrl
180
+ })
181
+ }
182
+
183
+ private getDefaultFrontendUrl(): string {
184
+ if (typeof window !== 'undefined') {
185
+ const hostname = window.location.hostname
186
+ if (hostname === 'localhost' || hostname === '127.0.0.1') {
187
+ return 'http://localhost:3002'
188
+ }
189
+ }
190
+ return 'https://sdks.verification.local'
191
+ }
192
+
193
+ private getDefaultApiUrl(): string {
194
+ if (typeof window !== 'undefined') {
195
+ const hostname = window.location.hostname
196
+ if (hostname === 'localhost' || hostname === '127.0.0.1') {
197
+ return 'http://localhost:3003'
198
+ }
199
+ }
200
+ return 'https://api.verification.local'
201
+ }
202
+
203
+ /**
204
+ * Detect if running on iOS device
205
+ */
206
+ private isIOSDevice(): boolean {
207
+ if (typeof window === 'undefined') return false
208
+ return /iPad|iPhone|iPod/.test(navigator.userAgent) ||
209
+ (navigator.platform === 'MacIntel' && (navigator as any).maxTouchPoints > 1)
210
+ }
211
+
212
+ /**
213
+ * Detect if running on Safari browser
214
+ */
215
+ private isSafariBrowser(): boolean {
216
+ if (typeof window === 'undefined') return false
217
+ return /Safari/.test(navigator.userAgent) && !/Chrome/.test(navigator.userAgent)
218
+ }
219
+
220
+ /**
221
+ * Detect if running on desktop device (not just viewport-based)
222
+ * Returns false if mockMobileDevice is enabled (treats as mobile)
223
+ */
224
+ private isDesktopDevice(): boolean {
225
+ // If mock mobile device is enabled, always return false (treat as mobile)
226
+ if (this.mockMobileDevice === true) {
227
+ console.log('[SDK]: Mock mobile device mode enabled - treating device as mobile (no handoff)')
228
+ return false
229
+ }
230
+
231
+ if (typeof window === 'undefined') return false
232
+
233
+ const userAgent = navigator.userAgent || navigator.vendor || (window as any).opera
234
+ const isMobileUA = /android|webos|iphone|ipad|ipod|blackberry|iemobile|opera mini|mobile/i.test(userAgent.toLowerCase())
235
+
236
+ // Additional checks for actual mobile devices
237
+ const hasTouchScreen = 'ontouchstart' in window || navigator.maxTouchPoints > 0
238
+ const isMobileOrientation = window.screen && 'orientation' in window.screen
239
+
240
+ // Desktop if: not mobile UA pattern OR (mobile UA but no real touch/orientation support)
241
+ const isDesktop = !isMobileUA || (isMobileUA && !hasTouchScreen && !isMobileOrientation)
242
+
243
+ return isDesktop
244
+ }
245
+
246
+ /**
247
+ * Resolve target element from string selector or HTMLElement
248
+ */
249
+ private resolveTargetElement(): HTMLElement | null {
250
+ if (!this.targetElement) return null
251
+
252
+ if (typeof this.targetElement === 'string') {
253
+ return document.querySelector(this.targetElement)
254
+ }
255
+
256
+ return this.targetElement
257
+ }
258
+
259
+ /**
260
+ * Probe iframe to check if key exists in iframe's localStorage
261
+ * Also checks backend to verify key exists and is active
262
+ */
263
+ private async probeIframeForKey(): Promise<string | null> {
264
+ return new Promise((resolve) => {
265
+ try {
266
+ const origin = window.location.origin
267
+ const iframe = document.createElement('iframe')
268
+ iframe.style.cssText = `position: fixed; top: -9999px; left: -9999px; width: 1px; height: 1px; border: none; opacity: 0; pointer-events: none;`
269
+
270
+ // Create a probe endpoint URL - we'll use /sign endpoint with a probe flag
271
+ const probeUrl = new URL(`${this.frontendUrl}/sign`)
272
+ probeUrl.searchParams.set('probe', 'true')
273
+ probeUrl.searchParams.set('origin', origin)
274
+ iframe.src = probeUrl.toString()
275
+
276
+ let timeout: NodeJS.Timeout | undefined
277
+ let backendCheckPromise: Promise<boolean> | null = null
278
+
279
+ const cleanup = () => {
280
+ window.removeEventListener('message', handleMessage)
281
+ if (iframe.parentNode) {
282
+ iframe.parentNode.removeChild(iframe)
283
+ }
284
+ if (timeout) {
285
+ clearTimeout(timeout)
286
+ }
287
+ }
288
+
289
+ const handleMessage = async (event: MessageEvent) => {
290
+ try {
291
+ const frontendOrigin = new URL(this.frontendUrl).origin
292
+ if (event.origin !== frontendOrigin) return
293
+
294
+ } catch (e) {
295
+ if (!event.origin.includes(window.location.hostname)) return
296
+ }
297
+
298
+ const data = event.data as any
299
+ if (data?.type === 'KEY_CHECK_RESULT') {
300
+ cleanup()
301
+ if (data.hasKey && data.keyId) {
302
+ // Key found in localStorage - verify it's active in backend
303
+ if (!backendCheckPromise) {
304
+ backendCheckPromise = this.checkKeyStatus(data.keyId)
305
+ }
306
+
307
+ const isActive = await backendCheckPromise
308
+ if (isActive) {
309
+ resolve(data.keyId)
310
+ } else {
311
+ console.log('[SDK]: Key found in localStorage but not active in backend, ignoring')
312
+ resolve(null)
313
+ }
314
+ } else {
315
+ resolve(null)
316
+ }
317
+ } else if (data?.type === 'KEY_CHECK_FAILED') {
318
+ cleanup()
319
+ resolve(null)
320
+ }
321
+ }
322
+
323
+ window.addEventListener('message', handleMessage)
324
+ document.body.appendChild(iframe)
325
+
326
+ // Short timeout to keep UX snappy
327
+ timeout = setTimeout(() => {
328
+ cleanup()
329
+ resolve(null)
330
+ }, 1500)
331
+ } catch {
332
+ resolve(null)
333
+ }
334
+ })
335
+ }
336
+
337
+ /**
338
+ * Lightweight API call to check if key exists and is active in backend
339
+ * Calls SDK frontend which proxies to SDK backend
340
+ */
341
+ private async checkKeyStatus(keyId: string): Promise<boolean> {
342
+ try {
343
+ const response = await fetch(`${this.frontendUrl}/api/key/check/${encodeURIComponent(keyId)}`, {
344
+ method: 'GET',
345
+ headers: { 'Content-Type': 'application/json' }
346
+ })
347
+
348
+ if (!response.ok) {
349
+ console.log('[SDK]: Key status check failed:', response.status)
350
+ return false
351
+ }
352
+
353
+ const result = await response.json() as any
354
+ const isActive = result.success && result.exists && result.active === true
355
+
356
+ if (!isActive) {
357
+ console.log('[SDK]: Key found but not active:', {
358
+ exists: result.exists,
359
+ active: result.active,
360
+ status: result.status
361
+ })
362
+ }
363
+
364
+ return isActive
365
+ } catch (error) {
366
+ console.error('[SDK]: Error checking key status:', error)
367
+ // If backend check fails, treat as invalid (fail closed for security)
368
+ // This ensures we don't use keys if we can't verify their status
369
+ return false
370
+ }
371
+ }
372
+
373
+ /**
374
+ * Open enrollment popup and wait for completion
375
+ */
376
+ private async enrollWithPopup(): Promise<string> {
377
+ if (!this.authAppId) {
378
+ throw new Error('authAppId is required for enrollment. Please configure authAppId in SDK config.')
379
+ }
380
+
381
+ // Generate session first
382
+ const sessionResponse = await fetch(`${this.frontendUrl}/api/sessions/generate`, {
383
+ method: 'POST',
384
+ headers: { 'Content-Type': 'application/json' },
385
+ body: JSON.stringify({ authAppId: this.authAppId })
386
+ })
387
+
388
+ if (!sessionResponse.ok) {
389
+ const errorData = await sessionResponse.json()
390
+ throw new Error(errorData.error || 'Failed to generate enrollment session')
391
+ }
392
+
393
+ const sessionData = await sessionResponse.json()
394
+ const sessionId = sessionData.data.sessionId
395
+
396
+ // authAppId is guaranteed to exist here due to check above
397
+ const appId = this.authAppId!
398
+
399
+ return new Promise((resolve, reject) => {
400
+ const origin = window.location.origin
401
+
402
+ // Build enrollment URL with branding colors from cache if available
403
+ const enrollUrlParams = new URLSearchParams({
404
+ authAppId: appId,
405
+ sessionId: sessionId,
406
+ returnTo: 'popup',
407
+ origin: origin
408
+ })
409
+
410
+ // Add branding colors to URL params if cached
411
+ if (this.brandingCache?.primary) {
412
+ enrollUrlParams.set('primaryColor', this.brandingCache.primary)
413
+ }
414
+ if (this.brandingCache?.background) {
415
+ enrollUrlParams.set('backgroundColor', this.brandingCache.background)
416
+ }
417
+ if (this.brandingCache?.secondary) {
418
+ enrollUrlParams.set('secondaryColor', this.brandingCache.secondary)
419
+ }
420
+ if (this.brandingCache?.textColor) {
421
+ enrollUrlParams.set('textColor', this.brandingCache.textColor)
422
+ }
423
+
424
+ const enrollUrl = `${this.frontendUrl}/enroll?${enrollUrlParams.toString()}`
425
+
426
+ console.log('[SDK]: Opening enrollment popup:', enrollUrl)
427
+
428
+ const popup = window.open(
429
+ enrollUrl,
430
+ 'trulyyou-enroll',
431
+ 'width=600,height=700,scrollbars=yes,resizable=yes'
432
+ )
433
+
434
+ if (!popup) {
435
+ reject(new Error('Failed to open enrollment popup. Please allow popups for this site.'))
436
+ return
437
+ }
438
+
439
+ let timeout: NodeJS.Timeout
440
+ let checkPopupClosed: NodeJS.Timeout
441
+ let successReceived = false
442
+
443
+ // Set up postMessage listener
444
+ const handleMessage = (event: MessageEvent) => {
445
+ console.log('[SDK]: Enrollment popup message received:', event.origin, event.data)
446
+
447
+ // Verify origin matches frontend URL
448
+ try {
449
+ const frontendOrigin = new URL(this.frontendUrl).origin
450
+ if (event.origin !== frontendOrigin) {
451
+ console.log('[SDK]: Origin mismatch, ignoring message')
452
+ return
453
+ }
454
+ } catch (e) {
455
+ // If frontendUrl is relative or invalid, check if it matches current origin
456
+ if (!event.origin.includes(window.location.hostname)) {
457
+ console.log('[SDK]: Origin mismatch, ignoring message')
458
+ return
459
+ }
460
+ }
461
+
462
+ if (event.data && event.data.type === 'ENROLLMENT_SUCCESS') {
463
+ successReceived = true
464
+
465
+ // Clear timeouts
466
+ if (timeout) clearTimeout(timeout)
467
+ if (checkPopupClosed) clearInterval(checkPopupClosed)
468
+
469
+ // Remove listener
470
+ window.removeEventListener('message', handleMessage)
471
+
472
+ // Get keyId from message first (avoids race condition with localStorage)
473
+ // Fallback to localStorage if not in message
474
+ const keyIdFromMessage = event.data.keyId
475
+ const trulyYouKeyId = keyIdFromMessage || localStorage.getItem('trulyYouKeyId')
476
+
477
+ if (trulyYouKeyId) {
478
+ console.log('[SDK]: Enrollment successful, keyId obtained:', trulyYouKeyId, keyIdFromMessage ? '(from message)' : '(from localStorage)')
479
+ // Also ensure it's in localStorage for future use
480
+ if (keyIdFromMessage && !localStorage.getItem('trulyYouKeyId')) {
481
+ localStorage.setItem('trulyYouKeyId', keyIdFromMessage)
482
+ }
483
+ resolve(trulyYouKeyId)
484
+ } else {
485
+ // Final fallback: wait a bit and check localStorage again (race condition mitigation)
486
+ setTimeout(() => {
487
+ const delayedKeyId = localStorage.getItem('trulyYouKeyId')
488
+ if (delayedKeyId) {
489
+ console.log('[SDK]: Enrollment successful, keyId obtained (delayed):', delayedKeyId)
490
+ resolve(delayedKeyId)
491
+ } else {
492
+ reject(new Error('Enrollment completed but no keyId found in message or localStorage'))
493
+ }
494
+ }, 500)
495
+ }
496
+ } else if (event.data && event.data.type === 'ENROLLMENT_ERROR') {
497
+ successReceived = true
498
+ if (timeout) clearTimeout(timeout)
499
+ if (checkPopupClosed) clearInterval(checkPopupClosed)
500
+ window.removeEventListener('message', handleMessage)
501
+ reject(new Error(event.data.error || 'Enrollment failed'))
502
+ }
503
+ }
504
+
505
+ window.addEventListener('message', handleMessage)
506
+
507
+ // Timeout after 10 minutes
508
+ timeout = setTimeout(() => {
509
+ if (!successReceived) {
510
+ window.removeEventListener('message', handleMessage)
511
+ if (checkPopupClosed) clearInterval(checkPopupClosed)
512
+ reject(new Error('Enrollment timeout'))
513
+ }
514
+ }, 10 * 60 * 1000)
515
+
516
+ // Check if popup was closed manually
517
+ checkPopupClosed = setInterval(() => {
518
+ if (popup.closed && !successReceived) {
519
+ clearInterval(checkPopupClosed)
520
+ clearTimeout(timeout)
521
+ window.removeEventListener('message', handleMessage)
522
+ reject(new Error('Enrollment popup was closed'))
523
+ }
524
+ }, 500)
525
+ })
526
+ }
527
+
528
+ /**
529
+ * Sign with iframe (mobile device)
530
+ */
531
+ private async signWithIframe(apiCallStructure: {
532
+ body: any
533
+ uri: string
534
+ method: string
535
+ headers?: any
536
+ }): Promise<SigningResult> {
537
+ return new Promise((resolve, reject) => {
538
+ try {
539
+ const signId = `sign_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`
540
+ const targetElement = this.resolveTargetElement()
541
+
542
+ // Create signing loading overlay if we have a target element
543
+ const createSigningLoadingOverlay = () => {
544
+ if (!targetElement) return null
545
+
546
+ const overlay = document.createElement('div')
547
+ overlay.id = `signing-overlay-${signId}`
548
+ overlay.style.cssText = `
549
+ position: absolute;
550
+ top: 0;
551
+ left: 0;
552
+ right: 0;
553
+ bottom: 0;
554
+ background-color: rgba(255, 255, 255, 0.9);
555
+ display: flex;
556
+ flex-direction: column;
557
+ align-items: center;
558
+ justify-content: center;
559
+ z-index: 10000;
560
+ border-radius: 8px;
561
+ `
562
+
563
+ const spinner = document.createElement('div')
564
+ spinner.style.cssText = `
565
+ width: 48px;
566
+ height: 48px;
567
+ border: 4px solid ${this.config.spinnerBgColor || '#e5e7eb'};
568
+ border-top-color: ${this.config.spinnerColor || '#2563eb'};
569
+ border-radius: 50%;
570
+ animation: spin 1s linear infinite;
571
+ margin-bottom: 16px;
572
+ `
573
+
574
+ const text = document.createElement('div')
575
+ text.textContent = 'Authenticating...'
576
+ text.style.cssText = `
577
+ color: ${this.config.spinnerTextColor || '#6b7280'};
578
+ font-size: 14px;
579
+ font-weight: 500;
580
+ `
581
+
582
+ // Add CSS animation
583
+ const style = document.createElement('style')
584
+ style.textContent = `
585
+ @keyframes spin {
586
+ 0% { transform: rotate(0deg); }
587
+ 100% { transform: rotate(360deg); }
588
+ }
589
+ `
590
+ document.head.appendChild(style)
591
+
592
+ overlay.appendChild(spinner)
593
+ overlay.appendChild(text)
594
+
595
+ return overlay
596
+ }
597
+
598
+ // Create iframe for signing
599
+ const iframe = document.createElement('iframe')
600
+ iframe.name = `trulyyou-sign-frame-${signId}`
601
+ iframe.scrolling = 'no'
602
+ iframe.setAttribute('scrolling', 'no')
603
+ // Allow WebAuthn in iframe (required for cross-origin iframes)
604
+ iframe.setAttribute('allow', 'publickey-credentials-get *; publickey-credentials-create *')
605
+
606
+ // Encode payload
607
+ const sortObjectByKeys = (obj: any): any => {
608
+ if (obj === null || typeof obj !== 'object' || obj instanceof Array) {
609
+ return obj
610
+ }
611
+ const sorted: any = {}
612
+ Object.keys(obj).sort().forEach(key => {
613
+ sorted[key] = sortObjectByKeys(obj[key])
614
+ })
615
+ return sorted
616
+ }
617
+
618
+ const createPayloadBase64 = (data: any, uri: string, method: string, queryParams: any) => {
619
+ const dataToProcess = data === undefined || data === null ? {} : data
620
+ const queryParamsToProcess = queryParams === undefined || queryParams === null ? {} : queryParams
621
+
622
+ const sortedData = sortObjectByKeys(dataToProcess)
623
+ const sortedQueryParams = sortObjectByKeys(queryParamsToProcess)
624
+
625
+ const encodedBody = btoa(JSON.stringify(sortedData))
626
+ const encodedQueryParams = btoa(JSON.stringify(sortedQueryParams))
627
+
628
+ const payloadData = {
629
+ method: method,
630
+ uriId: uri,
631
+ requestBody: encodedBody,
632
+ queryParams: encodedQueryParams,
633
+ }
634
+
635
+ return {
636
+ fullbase64: btoa(JSON.stringify(payloadData)),
637
+ sortedData: sortedData,
638
+ sortedQueryParams: sortedQueryParams,
639
+ encodedBody: encodedBody,
640
+ encodedQueryParams: encodedQueryParams,
641
+ }
642
+ }
643
+
644
+ const base64EncodedResponse = createPayloadBase64(
645
+ apiCallStructure.body || {},
646
+ apiCallStructure.uri,
647
+ apiCallStructure.method,
648
+ {}
649
+ )
650
+ const encodedPayload = base64EncodedResponse.fullbase64
651
+
652
+ // Prepare attestation data for iframe
653
+ const attestationDataForIframe = {
654
+ payload: encodedPayload,
655
+ apiCallStructure: apiCallStructure
656
+ }
657
+
658
+ const encodedData = btoa(JSON.stringify(attestationDataForIframe))
659
+
660
+ // Create form for POST submission to /sign/submit API route
661
+ const form = document.createElement('form')
662
+ form.method = 'POST'
663
+ form.action = `${this.frontendUrl}/sign/submit`
664
+ form.target = iframe.name
665
+ form.style.display = 'none'
666
+
667
+ // Add form data as hidden inputs
668
+ const attestationDataInput = document.createElement('input')
669
+ attestationDataInput.type = 'hidden'
670
+ attestationDataInput.name = 'attestationData'
671
+ attestationDataInput.value = encodedData
672
+ form.appendChild(attestationDataInput)
673
+
674
+ const signIdInput = document.createElement('input')
675
+ signIdInput.type = 'hidden'
676
+ signIdInput.name = 'signId'
677
+ signIdInput.value = signId
678
+ form.appendChild(signIdInput)
679
+
680
+ const modeInput = document.createElement('input')
681
+ modeInput.type = 'hidden'
682
+ modeInput.name = 'mode'
683
+ modeInput.value = 'embedded'
684
+ form.appendChild(modeInput)
685
+
686
+ // Determine if iframe will be visible (for mobile: not invisible, or iOS/Safari always visible)
687
+ const isIframeVisible = !this.invisible || this.isIOSDevice() || this.isSafariBrowser()
688
+
689
+ // Add branding colors and visibility flag to form data
690
+ const brandingInput = document.createElement('input')
691
+ brandingInput.type = 'hidden'
692
+ brandingInput.name = 'branding'
693
+ brandingInput.value = JSON.stringify({
694
+ primary: this.brandingCache?.primary || this.config.spinnerColor || '#2563eb',
695
+ background: this.brandingCache?.background || '#ffffff',
696
+ secondary: this.brandingCache?.secondary || '#2563eb',
697
+ textColor: this.brandingCache?.textColor || this.config.spinnerTextColor || '#6b7280',
698
+ spinnerColor: this.config.spinnerColor || this.brandingCache?.primary || '#2563eb',
699
+ spinnerBgColor: this.config.spinnerBgColor || '#e5e7eb',
700
+ spinnerTextColor: this.config.spinnerTextColor || this.brandingCache?.textColor || '#6b7280',
701
+ visible: isIframeVisible
702
+ })
703
+ form.appendChild(brandingInput)
704
+
705
+ // Mobile device logic (mock mobile OR actual mobile - both treated the same)
706
+ const isMobile = this.mockMobileDevice || !this.isDesktopDevice()
707
+
708
+ if (isMobile) {
709
+ // Mobile: NEVER full screen
710
+ // iOS/Safari: ALWAYS use target element if provided (WebAuthn requires visible iframe)
711
+ if (this.isIOSDevice() || this.isSafariBrowser()) {
712
+ if (targetElement) {
713
+ // iOS/Safari: Use target element (required for WebAuthn)
714
+ const targetStyle = window.getComputedStyle(targetElement)
715
+ if (targetStyle.position === 'static') {
716
+ targetElement.style.position = 'relative'
717
+ }
718
+ const originalHeight = targetElement.style.height || targetStyle.height
719
+ if (!originalHeight || originalHeight === 'auto') {
720
+ const computedHeight = targetElement.offsetHeight
721
+ if (computedHeight > 0) {
722
+ targetElement.style.height = `${computedHeight}px`
723
+ }
724
+ }
725
+ iframe.style.cssText = `position: absolute; top: 0; left: 0; width: 100%; height: 100%; border: none; z-index: 9999;`
726
+ targetElement.appendChild(iframe)
727
+ // No overlay needed - sign page is hidden when embedded
728
+ } else {
729
+ // iOS/Safari with no target element: must use invisible (can't use full screen on mobile)
730
+ console.warn('[SDK]: Mobile iOS/Safari with no target element - using invisible iframe (WebAuthn may fail)')
731
+ iframe.style.cssText = `position: fixed; top: -9999px; left: -9999px; width: 1px; height: 1px; border: none; opacity: 0; pointer-events: none;`
732
+ }
733
+ } else {
734
+ // Mobile non-iOS/Safari: invisible if invisible=true, target element if invisible=false
735
+ if (this.invisible) {
736
+ iframe.style.cssText = `position: fixed; top: -9999px; left: -9999px; width: 1px; height: 1px; border: none; opacity: 0; pointer-events: none;`
737
+ } else if (targetElement) {
738
+ // Visible iframe in target element
739
+ const targetStyle = window.getComputedStyle(targetElement)
740
+ if (targetStyle.position === 'static') {
741
+ targetElement.style.position = 'relative'
742
+ }
743
+ const originalHeight = targetElement.style.height || targetStyle.height
744
+ if (!originalHeight || originalHeight === 'auto') {
745
+ const computedHeight = targetElement.offsetHeight
746
+ if (computedHeight > 0) {
747
+ targetElement.style.height = `${computedHeight}px`
748
+ }
749
+ }
750
+ iframe.style.cssText = `position: absolute; top: 0; left: 0; width: 100%; height: 100%; border: none; z-index: 9999;`
751
+ targetElement.appendChild(iframe)
752
+ // No overlay - sign page is hidden when embedded
753
+ } else {
754
+ // No target element - use invisible (can't use full screen on mobile)
755
+ console.warn('[SDK]: Mobile device with invisible=true but no target element - using invisible iframe')
756
+ iframe.style.cssText = `position: fixed; top: -9999px; left: -9999px; width: 1px; height: 1px; border: none; opacity: 0; pointer-events: none;`
757
+ }
758
+ }
759
+ } else {
760
+ // Desktop device: can use full screen (only when opened directly on Device B after handoff)
761
+ // But when called from SDK, prefer target element or invisible
762
+ if (this.invisible) {
763
+ iframe.style.cssText = `position: fixed; top: -9999px; left: -9999px; width: 1px; height: 1px; border: none; opacity: 0; pointer-events: none;`
764
+ } else if (targetElement) {
765
+ // Visible iframe in target element
766
+ const targetStyle = window.getComputedStyle(targetElement)
767
+ if (targetStyle.position === 'static') {
768
+ targetElement.style.position = 'relative'
769
+ }
770
+ const originalHeight = targetElement.style.height || targetStyle.height
771
+ if (!originalHeight || originalHeight === 'auto') {
772
+ const computedHeight = targetElement.offsetHeight
773
+ if (computedHeight > 0) {
774
+ targetElement.style.height = `${computedHeight}px`
775
+ }
776
+ }
777
+ iframe.style.cssText = `position: absolute; top: 0; left: 0; width: 100%; height: 100%; border: none; z-index: 9999;`
778
+ targetElement.appendChild(iframe)
779
+ const overlay = createSigningLoadingOverlay()
780
+ if (overlay) {
781
+ targetElement.appendChild(overlay)
782
+ }
783
+ } else {
784
+ // Desktop: full screen overlay (only when no target element - Device B after handoff)
785
+ iframe.style.cssText = `position: fixed; top: 0; left: 0; width: 100%; height: 100%; border: none; z-index: 9999; background: white;`
786
+ }
787
+ }
788
+
789
+ // Append form to body
790
+ document.body.appendChild(form)
791
+
792
+ // Append iframe - only to body if not already appended to target element above
793
+ // Check if iframe has a parent (was already appended to target element)
794
+ if (!iframe.parentNode) {
795
+ document.body.appendChild(iframe)
796
+ }
797
+
798
+ let timeout: NodeJS.Timeout | undefined
799
+ let overlayElement: HTMLElement | null = null
800
+
801
+ const cleanup = () => {
802
+ window.removeEventListener('message', handleMessage)
803
+ if (form.parentNode) {
804
+ form.parentNode.removeChild(form)
805
+ }
806
+ if (iframe.parentNode) {
807
+ iframe.parentNode.removeChild(iframe)
808
+ }
809
+ if (overlayElement && overlayElement.parentNode) {
810
+ overlayElement.parentNode.removeChild(overlayElement)
811
+ }
812
+ if (timeout) {
813
+ clearTimeout(timeout)
814
+ }
815
+ // Remove height constraint from target element
816
+ if (targetElement && !this.invisible) {
817
+ targetElement.style.height = ''
818
+ }
819
+ }
820
+
821
+ const handleMessage = (event: MessageEvent) => {
822
+ try {
823
+ const frontendOrigin = new URL(this.frontendUrl).origin
824
+ if (event.origin !== frontendOrigin) return
825
+ } catch (e) {
826
+ if (!event.origin.includes(window.location.hostname)) return
827
+ }
828
+
829
+ const data = event.data as any
830
+ // Handle both SIGNING_COMPLETE and SIGNATURE_COMPLETE (sign page sends SIGNATURE_COMPLETE)
831
+ if ((data?.type === 'SIGNING_COMPLETE' || data?.type === 'SIGNATURE_COMPLETE') && data?.signId === signId) {
832
+ console.log('[SDK]: Signature complete message received:', { type: data.type, hasSignature: !!data.signature || !!data.signatureBase64 })
833
+ cleanup()
834
+ if (data.error) {
835
+ reject(new Error(data.error))
836
+ } else {
837
+ // Sign page sends signatureBase64, but we expect signature - use either field
838
+ const signature = data.signature || data.signatureBase64
839
+ if (!signature) {
840
+ reject(new Error('No signature in response'))
841
+ return
842
+ }
843
+ resolve({
844
+ signature: signature,
845
+ keyId: data.keyId || ''
846
+ })
847
+ }
848
+ } else if (data?.type === 'SIGNING_ERROR' && data?.signId === signId) {
849
+ cleanup()
850
+ reject(new Error(data.error || 'Signing failed'))
851
+ }
852
+ }
853
+
854
+ window.addEventListener('message', handleMessage)
855
+
856
+ // Submit form to iframe
857
+ form.submit()
858
+
859
+ // Remove form after submission
860
+ setTimeout(() => {
861
+ if (form.parentNode) {
862
+ form.parentNode.removeChild(form)
863
+ }
864
+ }, 100)
865
+
866
+ // Store overlay reference for cleanup
867
+ if (targetElement && !this.invisible) {
868
+ overlayElement = targetElement.querySelector(`#signing-overlay-${signId}`) as HTMLElement
869
+ }
870
+
871
+ // Timeout after 5 minutes
872
+ timeout = setTimeout(() => {
873
+ cleanup()
874
+ reject(new Error('Signing timeout'))
875
+ }, 5 * 60 * 1000)
876
+
877
+ } catch (error: any) {
878
+ reject(error)
879
+ }
880
+ })
881
+ }
882
+
883
+ /**
884
+ * Sign with WebSocket handoff (desktop device)
885
+ */
886
+ private async signWithWebSocketHandoff(apiCallStructure: {
887
+ body: any
888
+ uri: string
889
+ method: string
890
+ headers?: any
891
+ }, signatureId: string): Promise<SigningResult> {
892
+ return new Promise((resolve, reject) => {
893
+ try {
894
+ console.log('[SDK]: Desktop device detected, using WebSocket handoff with signatureId:', signatureId)
895
+
896
+ // Get primary color and app name from branding cache (already fetched on SDK instantiation)
897
+ const primaryColor = this.brandingCache?.primary || '#2563eb'
898
+ const authAppName = this.brandingCache?.name || 'TrulyYou'
899
+
900
+ // Encode signatureId, primaryColor, and authAppName as base64 JSON for URL parameter
901
+ const encodedData = {
902
+ signatureId: signatureId,
903
+ primaryColor: primaryColor,
904
+ authAppName: authAppName
905
+ }
906
+ const encodedThing = btoa(JSON.stringify(encodedData))
907
+
908
+ // Build sign URL with encoded parameter (no query params)
909
+ const signUrl = `${this.frontendUrl}/sign/${encodedThing}`
910
+
911
+ // Encode payload
912
+ const sortObjectByKeys = (obj: any): any => {
913
+ if (obj === null || typeof obj !== 'object' || obj instanceof Array) {
914
+ return obj
915
+ }
916
+ const sorted: any = {}
917
+ Object.keys(obj).sort().forEach(key => {
918
+ sorted[key] = sortObjectByKeys(obj[key])
919
+ })
920
+ return sorted
921
+ }
922
+
923
+ const createPayloadBase64 = (data: any, uri: string, method: string, queryParams: any) => {
924
+ const dataToProcess = data === undefined || data === null ? {} : data
925
+ const queryParamsToProcess = queryParams === undefined || queryParams === null ? {} : queryParams
926
+
927
+ const sortedData = sortObjectByKeys(dataToProcess)
928
+ const sortedQueryParams = sortObjectByKeys(queryParamsToProcess)
929
+
930
+ const encodedBody = btoa(JSON.stringify(sortedData))
931
+ const encodedQueryParams = btoa(JSON.stringify(sortedQueryParams))
932
+
933
+ const payloadData = {
934
+ method: method,
935
+ uriId: uri,
936
+ requestBody: encodedBody,
937
+ queryParams: encodedQueryParams,
938
+ }
939
+
940
+ return {
941
+ fullbase64: btoa(JSON.stringify(payloadData)),
942
+ sortedData: sortedData,
943
+ sortedQueryParams: sortedQueryParams,
944
+ encodedBody: encodedBody,
945
+ encodedQueryParams: encodedQueryParams,
946
+ }
947
+ }
948
+
949
+ const base64EncodedResponse = createPayloadBase64(
950
+ apiCallStructure.body || {},
951
+ apiCallStructure.uri,
952
+ apiCallStructure.method,
953
+ {}
954
+ )
955
+ const encodedPayload = base64EncodedResponse.fullbase64
956
+
957
+ // Prepare attestation data for sending
958
+ const attestationData = {
959
+ payload: encodedPayload,
960
+ apiCallStructure: apiCallStructure
961
+ }
962
+
963
+ // Parse realtimeUrl to extract host/port for Pusher
964
+ let pusherHost: string | undefined
965
+ let pusherPort: number | undefined
966
+ let forceTLS = false
967
+ let encrypted = true
968
+
969
+ try {
970
+ const realtimeUrlObj = new URL(this.realtimeUrl.startsWith('ws://') || this.realtimeUrl.startsWith('wss://')
971
+ ? this.realtimeUrl
972
+ : this.realtimeUrl.replace(/^http:/, 'ws:').replace(/^https:/, 'wss:'))
973
+
974
+ pusherHost = realtimeUrlObj.hostname
975
+ pusherPort = realtimeUrlObj.port ? parseInt(realtimeUrlObj.port, 10) : (realtimeUrlObj.protocol === 'wss:' ? 443 : 80)
976
+ forceTLS = realtimeUrlObj.protocol === 'wss:'
977
+ encrypted = realtimeUrlObj.protocol === 'wss:'
978
+ } catch (e) {
979
+ console.warn('[SDK]: Error parsing realtimeUrl, using defaults:', e)
980
+ pusherHost = '127.0.0.1'
981
+ pusherPort = 6001
982
+ }
983
+
984
+ // Get Pusher app key from config or use default
985
+ const pusherAppKey = this.config.pusherAppKey || 'app-key'
986
+
987
+ // Merge custom Pusher config with parsed values
988
+ // When using custom wsHost/wsPort, we need to set cluster to empty string to disable default cluster behavior
989
+ const pusherOptions: any = {
990
+ wsHost: this.config.pusherConfig?.wsHost || pusherHost,
991
+ wsPort: this.config.pusherConfig?.wsPort || pusherPort,
992
+ cluster: this.config.pusherConfig?.cluster || '', // Required when using custom wsHost/wsPort
993
+ forceTLS: this.config.pusherConfig?.forceTLS ?? forceTLS,
994
+ encrypted: this.config.pusherConfig?.encrypted ?? encrypted,
995
+ disableStats: this.config.pusherConfig?.disableStats ?? true,
996
+ enabledTransports: this.config.pusherConfig?.enabledTransports || ['ws', 'wss'],
997
+ }
998
+
999
+ console.log('[SDK]: Initializing Pusher with config:', pusherOptions)
1000
+ const pusher = new Pusher(pusherAppKey, pusherOptions)
1001
+
1002
+ // Use signatureId as the channel name (Soketi doesn't need 'public-' prefix)
1003
+ const channel = pusher.subscribe(signatureId)
1004
+ console.log('[SDK]: Attempting to subscribe to Pusher channel:', signatureId)
1005
+
1006
+ // Log when channel subscription succeeds and bind events
1007
+ channel.bind('pusher:subscription_succeeded', () => {
1008
+ console.log('[SDK]: ✅ Successfully subscribed to channel:', signatureId)
1009
+
1010
+ // Bind countdown events after subscription succeeds (backend uses client-* prefix)
1011
+ channel.bind('countdown_update', (data: any) => {
1012
+ console.log('[SDK]: 📊 Received countdown_update event:', data)
1013
+ })
1014
+ channel.bind('client-countdown_update', (data: any) => {
1015
+ console.log('[SDK]: 📊 Received client-countdown_update event:', data)
1016
+ })
1017
+
1018
+ channel.bind('countdown_expired', () => {
1019
+ console.log('[SDK]: ⏰ Received countdown_expired event')
1020
+ })
1021
+ channel.bind('client-countdown_expired', () => {
1022
+ console.log('[SDK]: ⏰ Received client-countdown_expired event')
1023
+ })
1024
+ })
1025
+
1026
+ // Store handlers in a variable that will be set later
1027
+ let handleCountdownUpdate: ((data: any) => void) | null = null
1028
+ let handleCountdownExpired: (() => void) | null = null
1029
+
1030
+ // Bind events early - handlers will be assigned after QR setup
1031
+ const earlyCountdownHandler = (data: any) => {
1032
+ console.log('[SDK]: 📊 Received countdown_update event (early bind):', data)
1033
+ if (handleCountdownUpdate) {
1034
+ handleCountdownUpdate(data)
1035
+ } else {
1036
+ console.warn('[SDK]: Countdown handler not ready yet, storing event')
1037
+ // Store event to process once handler is ready
1038
+ setTimeout(() => {
1039
+ if (handleCountdownUpdate) {
1040
+ handleCountdownUpdate(data)
1041
+ }
1042
+ }, 100)
1043
+ }
1044
+ }
1045
+ channel.bind('countdown_update', earlyCountdownHandler)
1046
+ channel.bind('client-countdown_update', earlyCountdownHandler) // Backend sends as client event
1047
+
1048
+ const earlyExpiredHandler = () => {
1049
+ console.log('[SDK]: ⏰ Received countdown_expired event (early bind)')
1050
+ if (handleCountdownExpired) {
1051
+ handleCountdownExpired()
1052
+ }
1053
+ }
1054
+ channel.bind('countdown_expired', earlyExpiredHandler)
1055
+ channel.bind('client-countdown_expired', earlyExpiredHandler) // Backend sends as client event
1056
+
1057
+ // Log connection errors
1058
+ pusher.connection.bind('error', (err: any) => {
1059
+ console.error('[SDK]: ❌ Pusher connection error:', err)
1060
+ })
1061
+
1062
+ // Log state changes
1063
+ pusher.connection.bind('state_change', (states: any) => {
1064
+ console.log('[SDK]: 🔄 Pusher state change:', states.previous, '->', states.current)
1065
+ })
1066
+
1067
+ let counterpartyConnected = false
1068
+ let timeout: NodeJS.Timeout
1069
+
1070
+ // Wait for Pusher connection
1071
+ pusher.connection.bind('connected', () => {
1072
+ console.log('[SDK]: Pusher connected, waiting for counterparty...')
1073
+
1074
+ // Notify server that desktop device connected (if QR is shown inline, already sent above)
1075
+ if (!targetElement || !qrContainer) {
1076
+ channel.trigger('client-device_connected', { type: 'device_connected', deviceType: 'desktop' })
1077
+ }
1078
+
1079
+ // Timeout after 5 minutes
1080
+ timeout = setTimeout(() => {
1081
+ cleanup()
1082
+ pusher.disconnect()
1083
+ reject(new Error('WebSocket handoff timeout - no counterparty connected'))
1084
+ }, 5 * 60 * 1000)
1085
+ })
1086
+
1087
+ // Listen for mobile device connection (client event from sign page)
1088
+ // Device B now fetches payload from API, so we just mark as connected
1089
+ channel.bind('client-device_connected', (data: any) => {
1090
+ // Only react to mobile device connections (ignore our own desktop connection)
1091
+ if (data.type === 'device_connected' && data.deviceType === 'mobile' && !counterpartyConnected) {
1092
+ counterpartyConnected = true
1093
+ console.log('[SDK]: Mobile device connected (payload will be fetched from API)')
1094
+ // No longer sending payload via socket - Device B fetches from API
1095
+ }
1096
+ })
1097
+
1098
+ // Listen for signature result (client event from mobile)
1099
+ channel.bind('client-signature_result', (data: any) => {
1100
+ console.log('[SDK]: Signature result event received, raw data:', data)
1101
+ console.log('[SDK]: Data keys:', Object.keys(data || {}))
1102
+ console.log('[SDK]: counterpartyConnected:', counterpartyConnected)
1103
+
1104
+ // Extract signature from data - handle both direct and wrapped formats
1105
+ const signature = data?.signature || data?.data?.signature
1106
+ const error = data?.error || data?.data?.error
1107
+ const keyId = data?.keyId || data?.data?.keyId
1108
+
1109
+ console.log('[SDK]: Extracted values - signature:', !!signature, 'error:', error, 'keyId:', keyId)
1110
+ console.log('[SDK]: Full socket data received:', JSON.stringify(data, null, 2))
1111
+
1112
+ // Prevent multiple processing
1113
+ if (!counterpartyConnected) {
1114
+ console.warn('[SDK]: Received signature result but counterparty not marked as connected yet. Waiting...')
1115
+ // Don't return - might be a timing issue, try to process anyway
1116
+ }
1117
+
1118
+ clearTimeout(timeout)
1119
+
1120
+ if (error) {
1121
+ console.error('[SDK]: Signature error:', error)
1122
+ cleanup()
1123
+ pusher.disconnect()
1124
+ reject(new Error(error))
1125
+ return
1126
+ }
1127
+
1128
+ if (signature) {
1129
+ console.log('[SDK]: Signature received successfully, resolving promise...')
1130
+ // Use keyId from Device B (not localStorage) - Device B sent this via socket
1131
+ const deviceBKeyId = keyId || ''
1132
+ if (!deviceBKeyId) {
1133
+ console.warn('[SDK]: ⚠️ WARNING - No keyId received from Device B via socket! Data:', data)
1134
+ }
1135
+ console.log('[SDK]: ✅ Using keyId from Device B:', deviceBKeyId, '(extracted from socket)')
1136
+ const result: SigningResult = {
1137
+ signature: signature,
1138
+ keyId: deviceBKeyId,
1139
+ signatureId: signatureId
1140
+ }
1141
+ console.log('[SDK]: Resolving with SigningResult:', {
1142
+ signatureLength: result.signature?.length,
1143
+ keyId: result.keyId,
1144
+ signatureId: result.signatureId,
1145
+ keyIdSource: deviceBKeyId ? 'Device B (socket)' : 'MISSING - will fallback to localStorage'
1146
+ })
1147
+
1148
+ cleanup()
1149
+ pusher.disconnect()
1150
+ resolve(result)
1151
+ return
1152
+ }
1153
+
1154
+ console.error('[SDK]: Invalid signature result data - no signature or error:', JSON.stringify(data))
1155
+ cleanup()
1156
+ pusher.disconnect()
1157
+ reject(new Error('Invalid signature result received'))
1158
+ })
1159
+
1160
+ pusher.connection.bind('error', (error: any) => {
1161
+ console.error('[SDK]: Pusher connection error:', error)
1162
+ clearTimeout(timeout)
1163
+ pusher.disconnect()
1164
+ reject(new Error('Pusher connection error'))
1165
+ })
1166
+
1167
+ pusher.connection.bind('disconnected', () => {
1168
+ console.log('[SDK]: Pusher disconnected')
1169
+ clearTimeout(timeout)
1170
+ })
1171
+
1172
+ // Store signatureId and signUrl for enrollment page to use
1173
+ // This will be handled by opening enrollment page with signatureId
1174
+ // For now, we'll need to modify the enrollment flow to handle this
1175
+
1176
+ // Actually, we need to open enrollment page instead of sign page
1177
+ // The enrollment page will show QR code and handle WebSocket
1178
+ // Let's modify the approach - we'll emit an event that the app can listen to
1179
+
1180
+ // Store attestation data in sessionStorage for sign page to access
1181
+ sessionStorage.setItem(`signHandoff_${signatureId}`, JSON.stringify(attestationData))
1182
+
1183
+ // Show QR code inline in target element (Device A - desktop)
1184
+ // Device B will scan QR code and open the sign page URL
1185
+ const targetElement = this.resolveTargetElement()
1186
+ let qrContainer: HTMLElement | null = null
1187
+ let checkWindowClosed: NodeJS.Timeout | null = null
1188
+
1189
+ if (targetElement) {
1190
+ // Create QR code container
1191
+ qrContainer = document.createElement('div')
1192
+ qrContainer.id = `qr-handoff-${signatureId}`
1193
+ qrContainer.style.cssText = `
1194
+ position: absolute;
1195
+ top: 0;
1196
+ left: 0;
1197
+ right: 0;
1198
+ bottom: 0;
1199
+ width: 100%;
1200
+ height: 100%;
1201
+ max-width: 100%;
1202
+ max-height: 100%;
1203
+ overflow: hidden;
1204
+ background-color: rgba(255, 255, 255, 0.98);
1205
+ display: flex;
1206
+ flex-direction: column;
1207
+ align-items: center;
1208
+ justify-content: center;
1209
+ z-index: 10001;
1210
+ padding: 0;
1211
+ box-sizing: border-box;
1212
+ `
1213
+
1214
+ // Create QR code wrapper with countdown SVG overlay
1215
+ const qrWrapper = document.createElement('div')
1216
+ qrWrapper.style.cssText = `
1217
+ position: relative;
1218
+ display: flex;
1219
+ align-items: center;
1220
+ justify-content: center;
1221
+ width: 100%;
1222
+ height: 100%;
1223
+ max-width: 100%;
1224
+ max-height: 100%;
1225
+ `
1226
+
1227
+ // Create loading spinner (shown while QR loads)
1228
+ const spinnerContainer = document.createElement('div')
1229
+ spinnerContainer.style.cssText = `
1230
+ position: absolute;
1231
+ top: 50%;
1232
+ left: 50%;
1233
+ transform: translate(-50%, -50%);
1234
+ width: 48px;
1235
+ height: 48px;
1236
+ z-index: 1;
1237
+ display: flex;
1238
+ align-items: center;
1239
+ justify-content: center;
1240
+ `
1241
+
1242
+ const spinner = document.createElement('div')
1243
+ spinner.style.cssText = `
1244
+ width: 48px;
1245
+ height: 48px;
1246
+ border: 4px solid ${this.brandingCache?.primary ? `${this.brandingCache.primary}20` : '#e5e7eb'};
1247
+ border-top-color: ${this.brandingCache?.primary || '#2563eb'};
1248
+ border-radius: 50%;
1249
+ animation: spin 1s linear infinite;
1250
+ `
1251
+
1252
+ // Add spin animation
1253
+ const style = document.createElement('style')
1254
+ style.textContent = `
1255
+ @keyframes spin {
1256
+ to { transform: rotate(360deg); }
1257
+ }
1258
+ `
1259
+ document.head.appendChild(style)
1260
+
1261
+ spinnerContainer.appendChild(spinner)
1262
+
1263
+ // Create QR code with app icon overlay in center
1264
+ const qrImage = document.createElement('img')
1265
+ qrImage.alt = 'QR Code for mobile signing (click to copy URL)'
1266
+ qrImage.style.cssText = `
1267
+ width: 100%;
1268
+ height: 100%;
1269
+ max-width: 100%;
1270
+ max-height: 100%;
1271
+ aspect-ratio: 1;
1272
+ object-fit: contain;
1273
+ cursor: pointer;
1274
+ transition: opacity 0.2s;
1275
+ position: relative;
1276
+ z-index: 2;
1277
+ display: none;
1278
+ opacity: 0;
1279
+ `
1280
+ qrImage.title = 'Click to copy URL to clipboard'
1281
+
1282
+ // Show spinner initially, hide when QR loads
1283
+ qrImage.addEventListener('load', () => {
1284
+ console.log('[SDK]: QR image loaded')
1285
+ spinnerContainer.style.display = 'none'
1286
+ qrImage.style.display = 'block'
1287
+ qrImage.style.opacity = '1'
1288
+ // Recalculate SVG position after QR is loaded and visible
1289
+ setTimeout(() => {
1290
+ if (setupCountdownRect) {
1291
+ setupCountdownRect()
1292
+ }
1293
+ }, 50)
1294
+ })
1295
+
1296
+ qrImage.addEventListener('error', () => {
1297
+ console.error('[SDK]: QR image failed to load')
1298
+ spinnerContainer.style.display = 'none'
1299
+ // Still try to show QR even if load failed
1300
+ qrImage.style.display = 'block'
1301
+ qrImage.style.opacity = '0.5'
1302
+ })
1303
+
1304
+ // Create countdown SVG overlay (positioned absolutely over QR image, matching exact size)
1305
+ const countdownSvg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
1306
+ countdownSvg.id = `countdown-svg-${signatureId}`
1307
+
1308
+ // Wait for QR image to load and get actual rendered dimensions, then size SVG to match exactly
1309
+ const setupCountdownRect = () => {
1310
+ // Get actual rendered size and position of QR image (after object-fit: contain is applied)
1311
+ const qrRect = qrImage.getBoundingClientRect()
1312
+ const qrWrapperRect = qrWrapper.getBoundingClientRect()
1313
+
1314
+ // QR image with object-fit: contain and aspect-ratio: 1 will be square
1315
+ // and fit inside the wrapper. Get its actual rendered size.
1316
+ const actualQRSize = Math.min(qrRect.width, qrRect.height)
1317
+
1318
+ // Make SVG slightly bigger than QR to create border space (5% larger on each side = 10% total)
1319
+ const borderPadding = actualQRSize * 0.05 // 5% padding on each side
1320
+ const svgSize = actualQRSize + (borderPadding * 2) // Total SVG size with border space
1321
+
1322
+ // Calculate position relative to wrapper (not viewport)
1323
+ // Use getBoundingClientRect for accurate positioning (accounts for transforms and object-fit)
1324
+ // For centered QR with flexbox, calculate offset from QR center to wrapper center
1325
+ const qrCenterX = qrRect.left + qrRect.width / 2 - qrWrapperRect.left
1326
+ const qrCenterY = qrRect.top + qrRect.height / 2 - qrWrapperRect.top
1327
+
1328
+ // SVG should be centered over QR but slightly bigger - position from top-left
1329
+ const offsetX = qrCenterX - svgSize / 2
1330
+ const offsetY = qrCenterY - svgSize / 2
1331
+
1332
+ // Use getBoundingClientRect values as they account for transforms and positioning
1333
+ console.log('[SDK]: QR positioning - centered offset:', offsetX, offsetY, 'QR size:', actualQRSize, 'SVG size:', svgSize)
1334
+
1335
+ // Position and size SVG to be slightly bigger than QR (with border space)
1336
+ countdownSvg.style.cssText = `
1337
+ position: absolute;
1338
+ top: ${offsetY}px;
1339
+ left: ${offsetX}px;
1340
+ width: ${svgSize}px;
1341
+ height: ${svgSize}px;
1342
+ pointer-events: none;
1343
+ z-index: 2;
1344
+ transform: rotate(0deg);
1345
+ transform-origin: center center;
1346
+ `
1347
+ countdownSvg.setAttribute('viewBox', `0 0 ${svgSize} ${svgSize}`)
1348
+ countdownSvg.setAttribute('preserveAspectRatio', 'xMidYMid meet')
1349
+
1350
+ // Rounded rectangle parameters - use percentages relative to viewBox
1351
+ // The SVG is bigger than QR to create border space, but rect should fit within SVG bounds
1352
+ const rectWidth = svgSize
1353
+ const rectHeight = svgSize
1354
+ const cornerRadius = svgSize * 0.04 // 4% of size for rounded corners (scales with QR)
1355
+ const strokeWidth = svgSize * 0.06 // 6% of size for stroke (3x thicker - scales with QR)
1356
+
1357
+ // Clear existing rects if any
1358
+ while (countdownSvg.firstChild) {
1359
+ countdownSvg.removeChild(countdownSvg.firstChild)
1360
+ }
1361
+
1362
+ // Position rect so stroke sits OUTSIDE the QR image
1363
+ // QR is centered in SVG, so it goes from borderPadding to svgSize - borderPadding
1364
+ // We want the stroke to sit outside this, so:
1365
+ // - Rect inner edge should be at borderPadding (QR edge)
1366
+ // - Stroke is centered on path, so x = borderPadding - strokeWidth/2 (so stroke extends outside)
1367
+ // - Rect width should be actualQRSize + strokeWidth (to account for stroke extending outside)
1368
+ const rectX = borderPadding - strokeWidth / 2
1369
+ const rectY = borderPadding - strokeWidth / 2
1370
+ const rectW = actualQRSize + strokeWidth
1371
+ const rectH = actualQRSize + strokeWidth
1372
+
1373
+ // Background rounded rectangle outline - positioned OUTSIDE QR bounds
1374
+ const bgRect = document.createElementNS('http://www.w3.org/2000/svg', 'rect')
1375
+ bgRect.setAttribute('x', rectX.toString())
1376
+ bgRect.setAttribute('y', rectY.toString())
1377
+ bgRect.setAttribute('width', rectW.toString())
1378
+ bgRect.setAttribute('height', rectH.toString())
1379
+ bgRect.setAttribute('rx', cornerRadius.toString())
1380
+ bgRect.setAttribute('ry', cornerRadius.toString())
1381
+ bgRect.setAttribute('fill', 'none')
1382
+ bgRect.setAttribute('stroke', '#e5e7eb')
1383
+ bgRect.setAttribute('stroke-width', strokeWidth.toString())
1384
+ countdownSvg.appendChild(bgRect)
1385
+
1386
+ // Progress rounded rectangle outline (animated) - same positioning, OUTSIDE QR bounds
1387
+ const progressRect = document.createElementNS('http://www.w3.org/2000/svg', 'rect')
1388
+ progressRect.id = `countdown-progress-${signatureId}`
1389
+ progressRect.setAttribute('x', rectX.toString())
1390
+ progressRect.setAttribute('y', rectY.toString())
1391
+ progressRect.setAttribute('width', rectW.toString())
1392
+ progressRect.setAttribute('height', rectH.toString())
1393
+ progressRect.setAttribute('rx', cornerRadius.toString())
1394
+ progressRect.setAttribute('ry', cornerRadius.toString())
1395
+ progressRect.setAttribute('fill', 'none')
1396
+ progressRect.setAttribute('stroke', this.brandingCache?.primary || '#2563eb')
1397
+ progressRect.setAttribute('stroke-width', strokeWidth.toString())
1398
+ progressRect.setAttribute('stroke-linecap', 'round')
1399
+ progressRect.setAttribute('stroke-linejoin', 'round')
1400
+ // Recalculate perimeter with new dimensions (rect is outside QR bounds)
1401
+ const actualInnerWidth = rectW
1402
+ const actualInnerHeight = rectH
1403
+ const actualInnerRx = cornerRadius
1404
+ const actualStraightEdges = 2 * (actualInnerWidth + actualInnerHeight - 2 * actualInnerRx)
1405
+ const actualCornerArcs = 2 * Math.PI * actualInnerRx
1406
+ const actualPerimeter = actualStraightEdges + actualCornerArcs
1407
+ progressRect.setAttribute('stroke-dasharray', actualPerimeter.toString())
1408
+ progressRect.setAttribute('stroke-dashoffset', actualPerimeter.toString())
1409
+ progressRect.style.transition = 'stroke-dashoffset 0.3s linear, stroke 0.3s linear'
1410
+ countdownSvg.appendChild(progressRect)
1411
+
1412
+ // Return actual perimeter for use in countdown updates
1413
+ return { perimeter: actualPerimeter }
1414
+ }
1415
+
1416
+ // Setup when image loads or immediately if already loaded
1417
+ if (qrImage.complete && qrImage.naturalWidth > 0) {
1418
+ setupCountdownRect()
1419
+ } else {
1420
+ qrImage.addEventListener('load', () => {
1421
+ setupCountdownRect()
1422
+ })
1423
+ // Fallback setup with default values
1424
+ setupCountdownRect()
1425
+ }
1426
+
1427
+ // Countdown state
1428
+ let countdownState = { remaining: 30, total: 30 }
1429
+
1430
+ // Listen for countdown updates (bound both early and in subscription_succeeded)
1431
+ // Assign handler to the stored variable so early binds can access it
1432
+ handleCountdownUpdate = (data: any) => {
1433
+ console.log('[SDK]: 🎯 Processing countdown_update:', data)
1434
+ if (data && (data.type === 'countdown_update' || data.timeRemaining !== undefined)) {
1435
+ countdownState = {
1436
+ remaining: data.timeRemaining ?? countdownState.remaining,
1437
+ total: data.totalTime ?? countdownState.total
1438
+ }
1439
+ console.log(`[SDK]: 📊 Updating countdown rectangle: ${countdownState.remaining}/${countdownState.total}`)
1440
+
1441
+ // Calculate perimeter using actual SVG size from viewBox
1442
+ const viewBoxAttr = countdownSvg.getAttribute('viewBox')
1443
+ const svgSize = viewBoxAttr ? parseFloat(viewBoxAttr.split(' ')[2]) || 100 : 100
1444
+ const cornerRadius = svgSize * 0.04
1445
+ const strokeWidth = svgSize * 0.06 // 3x thicker
1446
+ // Calculate border padding (5% of QR size, but we need to reverse-calculate from SVG size)
1447
+ // SVG = QR + 2*borderPadding, so borderPadding = (SVG - QR) / 2
1448
+ // We approximate QR as ~91% of SVG (since SVG = QR * 1.1, so QR ≈ SVG / 1.1)
1449
+ const estimatedQRSize = svgSize / 1.1
1450
+ const borderPadding = (svgSize - estimatedQRSize) / 2
1451
+ // Rectangle is positioned OUTSIDE QR (at borderPadding - strokeWidth/2)
1452
+ const rectW = estimatedQRSize + strokeWidth
1453
+ const rectH = estimatedQRSize + strokeWidth
1454
+ const actualInnerWidth = rectW
1455
+ const actualInnerHeight = rectH
1456
+ const actualInnerRx = cornerRadius
1457
+ const straightEdges = 2 * (actualInnerWidth + actualInnerHeight - 2 * actualInnerRx)
1458
+ const cornerArcs = 2 * Math.PI * actualInnerRx
1459
+ const currentPerimeter = straightEdges + cornerArcs
1460
+
1461
+ const percentage = countdownState.remaining / countdownState.total
1462
+ const strokeDashoffset = currentPerimeter * (1 - percentage)
1463
+
1464
+ // Update the progress rect if it exists
1465
+ const progressRectElement = document.getElementById(`countdown-progress-${signatureId}`) as SVGElement | null
1466
+ if (progressRectElement && progressRectElement instanceof SVGRectElement) {
1467
+ progressRectElement.setAttribute('stroke-dashoffset', strokeDashoffset.toString())
1468
+ console.log(`[SDK]: 📊 Set stroke-dashoffset to: ${strokeDashoffset} (percentage: ${percentage}, perimeter: ${currentPerimeter})`)
1469
+
1470
+ // Change color when low
1471
+ if (countdownState.remaining <= 5) {
1472
+ progressRectElement.setAttribute('stroke', '#ef4444')
1473
+ } else if (countdownState.remaining <= 10) {
1474
+ progressRectElement.setAttribute('stroke', '#f59e0b')
1475
+ } else {
1476
+ progressRectElement.setAttribute('stroke', this.brandingCache?.primary || '#2563eb')
1477
+ }
1478
+ }
1479
+ } else {
1480
+ console.warn('[SDK]: Invalid countdown_update data:', data)
1481
+ }
1482
+ }
1483
+
1484
+ handleCountdownExpired = () => {
1485
+ console.log('[SDK]: ⏰ Countdown expired - cleaning up and resetting state')
1486
+
1487
+ // Clean up Pusher connection first
1488
+ if (pusher && pusher.connection.state === 'connected') {
1489
+ pusher.disconnect()
1490
+ }
1491
+
1492
+ // Remove QR container and all its children completely
1493
+ if (qrContainer) {
1494
+ // Remove all child elements
1495
+ while (qrContainer.firstChild) {
1496
+ qrContainer.removeChild(qrContainer.firstChild)
1497
+ }
1498
+ // Remove container from DOM
1499
+ if (qrContainer.parentNode) {
1500
+ qrContainer.parentNode.removeChild(qrContainer)
1501
+ }
1502
+ qrContainer = null
1503
+ }
1504
+
1505
+ // Reset countdown SVG reference
1506
+ const countdownSvgElement = document.getElementById(`countdown-svg-${signatureId}`)
1507
+ if (countdownSvgElement && countdownSvgElement.parentNode) {
1508
+ countdownSvgElement.parentNode.removeChild(countdownSvgElement)
1509
+ }
1510
+
1511
+ // Clean up any other references
1512
+ cleanup()
1513
+
1514
+ // Silently reject - don't show alert, just reset state
1515
+ reject(new Error('Countdown expired'))
1516
+ }
1517
+
1518
+ channel.bind('countdown_update', handleCountdownUpdate)
1519
+ channel.bind('client-countdown_update', handleCountdownUpdate) // Backend sends as client event
1520
+ channel.bind('countdown_expired', handleCountdownExpired)
1521
+ channel.bind('client-countdown_expired', handleCountdownExpired) // Backend sends as client event
1522
+
1523
+ qrWrapper.appendChild(spinnerContainer)
1524
+ qrWrapper.appendChild(qrImage)
1525
+ qrWrapper.appendChild(countdownSvg)
1526
+
1527
+ // Add click handler to copy URL to clipboard
1528
+ qrImage.addEventListener('click', async () => {
1529
+ try {
1530
+ await navigator.clipboard.writeText(signUrl)
1531
+ console.log('[SDK]: URL copied to clipboard:', signUrl)
1532
+
1533
+ // Visual feedback - temporarily change opacity
1534
+ const originalOpacity = qrImage.style.opacity || '1'
1535
+ qrImage.style.opacity = '0.7'
1536
+ setTimeout(() => {
1537
+ qrImage.style.opacity = originalOpacity
1538
+ }, 200)
1539
+ } catch (err) {
1540
+ console.error('[SDK]: Failed to copy URL to clipboard:', err)
1541
+ // Fallback for older browsers
1542
+ const textArea = document.createElement('textarea')
1543
+ textArea.value = signUrl
1544
+ textArea.style.position = 'fixed'
1545
+ textArea.style.opacity = '0'
1546
+ document.body.appendChild(textArea)
1547
+ textArea.select()
1548
+ try {
1549
+ document.execCommand('copy')
1550
+ console.log('[SDK]: URL copied to clipboard (fallback method)')
1551
+ } catch (fallbackErr) {
1552
+ console.error('[SDK]: Fallback copy failed:', fallbackErr)
1553
+ }
1554
+ document.body.removeChild(textArea)
1555
+ }
1556
+ })
1557
+
1558
+ // Generate QR code with icon overlay
1559
+ this.generateQRCodeWithIcon(signUrl, qrImage).catch((err: any) => {
1560
+ console.warn('[SDK]: Error generating QR code with icon, using fallback:', err)
1561
+ // Fallback to simple QR code without icon
1562
+ const qrCodeUrl = `https://api.qrserver.com/v1/create-qr-code/?size=300x300&data=${encodeURIComponent(signUrl)}`
1563
+ qrImage.src = qrCodeUrl
1564
+ })
1565
+
1566
+ qrContainer.appendChild(qrWrapper)
1567
+
1568
+ // Make target element position relative if needed and add overflow constraints
1569
+ const targetStyle = window.getComputedStyle(targetElement)
1570
+ if (targetStyle.position === 'static') {
1571
+ targetElement.style.position = 'relative'
1572
+ }
1573
+ // Ensure target element contains the overlay
1574
+ if (targetStyle.overflow === 'visible') {
1575
+ targetElement.style.overflow = 'hidden'
1576
+ }
1577
+
1578
+ targetElement.appendChild(qrContainer)
1579
+ } else {
1580
+ // No target element - fallback to opening in new window
1581
+ console.warn('[SDK]: No target element available, opening sign page in new window')
1582
+ const signWindow = window.open(signUrl, '_blank', 'width=600,height=700')
1583
+
1584
+ if (!signWindow) {
1585
+ pusher.disconnect()
1586
+ reject(new Error('Failed to open sign page. Please allow popups for this site.'))
1587
+ return
1588
+ }
1589
+
1590
+ // Monitor sign window for closure
1591
+ checkWindowClosed = setInterval(() => {
1592
+ if (signWindow.closed) {
1593
+ if (checkWindowClosed) clearInterval(checkWindowClosed)
1594
+ clearTimeout(timeout)
1595
+ pusher.disconnect()
1596
+ reject(new Error('Sign window was closed'))
1597
+ }
1598
+ }, 500)
1599
+ }
1600
+
1601
+ // Store pusher reference for cleanup
1602
+ let pusherInstance: Pusher | null = pusher
1603
+
1604
+ // Cleanup on completion
1605
+ const cleanup = () => {
1606
+ if (qrContainer && qrContainer.parentNode) {
1607
+ qrContainer.parentNode.removeChild(qrContainer)
1608
+ }
1609
+ if (checkWindowClosed) {
1610
+ clearInterval(checkWindowClosed)
1611
+ }
1612
+ clearTimeout(timeout)
1613
+ if (pusherInstance) {
1614
+ pusherInstance.disconnect()
1615
+ pusherInstance = null
1616
+ }
1617
+ sessionStorage.removeItem(`signHandoff_${signatureId}`)
1618
+ }
1619
+
1620
+ } catch (error: any) {
1621
+ reject(error)
1622
+ }
1623
+ })
1624
+ }
1625
+
1626
+ /**
1627
+ * Sign a payload with WebAuthn without a server-generated challenge
1628
+ * Encodes the payload directly as the challenge
1629
+ * Uses iframe to extract key from iframe's localStorage, not host
1630
+ */
1631
+ async signPayload(apiCallStructure: {
1632
+ body: any
1633
+ uri: string
1634
+ method: string
1635
+ headers?: any
1636
+ }, signatureId?: string): Promise<SigningResult> {
1637
+ try {
1638
+ // Step 1: Check if desktop device first - desktop uses WebSocket handoff (no key probe needed)
1639
+ if (this.isDesktopDevice()) {
1640
+ console.log('[SDK]: Desktop device detected, using WebSocket handoff (skipping key probe)')
1641
+ if (!signatureId) {
1642
+ throw new Error('signatureId is required for desktop handoff')
1643
+ }
1644
+ return await this.signWithWebSocketHandoff(apiCallStructure, signatureId)
1645
+ }
1646
+
1647
+ // Step 2: Mobile device - probe iframe to check if key exists in iframe's localStorage and is active in backend
1648
+ console.log('[SDK]: Mobile device detected, probing iframe for existing key...')
1649
+ let keyId = await this.probeIframeForKey()
1650
+
1651
+ if (!keyId) {
1652
+ // Step 3: No valid active key found (missing, invalid, revoked, or expired) - open enrollment popup
1653
+ console.log('[SDK]: No valid active key found (missing, invalid, revoked, or expired), opening enrollment popup...')
1654
+ await this.enrollWithPopup()
1655
+
1656
+ // Step 4: After enrollment, probe again to verify new key is active
1657
+ keyId = await this.probeIframeForKey()
1658
+
1659
+ if (!keyId) {
1660
+ throw new Error('Enrollment completed but no valid active key found')
1661
+ }
1662
+ }
1663
+
1664
+ console.log('[SDK]: Valid active key found, signing with keyId:', keyId)
1665
+
1666
+ // Step 5: Sign using iframe (key is extracted from iframe's localStorage)
1667
+ console.log('[SDK]: Using iframe signing for mobile device')
1668
+ return await this.signWithIframe(apiCallStructure)
1669
+
1670
+ } catch (error: any) {
1671
+ console.error('[SDK]: Signing error:', error)
1672
+ throw error
1673
+ }
1674
+ }
1675
+
1676
+ /**
1677
+ * Fetch with automatic payload signing
1678
+ */
1679
+ async fetchWithSignature(
1680
+ url: string,
1681
+ options: FetchOptions = {}
1682
+ ): Promise<FetchResult> {
1683
+ // Declare signatureId at function scope so it's accessible in catch block
1684
+ let signatureId: string | undefined = undefined
1685
+ try {
1686
+ console.log('[SDK]: fetchWithSignature called for:', url)
1687
+
1688
+ // Parse URL to get path and base URL
1689
+ const urlObj = new URL(url)
1690
+ const uriPath = urlObj.pathname + urlObj.search
1691
+ const baseUrl = `${urlObj.protocol}//${urlObj.host}`
1692
+
1693
+ // Prepare API call structure
1694
+ const apiCallStructure = {
1695
+ body: options.body ? JSON.parse(options.body as string) : {},
1696
+ uri: uriPath,
1697
+ method: (options.method || 'GET').toUpperCase(),
1698
+ headers: options.headers || {}
1699
+ }
1700
+
1701
+ console.log('[SDK]: API call structure:', apiCallStructure)
1702
+
1703
+ // Check if domain + signature template match (if authAppId is configured)
1704
+ if (this.authAppId) {
1705
+ try {
1706
+ // Generate signatureId first (for desktop handoff, we need it before signing)
1707
+ // Generate a long, unique signatureId
1708
+ signatureId = `sig_${Date.now()}_${Math.random().toString(36).substring(2, 15)}${Math.random().toString(36).substring(2, 15)}${Math.random().toString(36).substring(2, 15)}${Math.random().toString(36).substring(2, 15)}`
1709
+
1710
+ // Get keyId and userId from localStorage
1711
+ const keyId = localStorage.getItem('trulyYouKeyId')
1712
+ const userIdStr = localStorage.getItem('trulyYouUserId')
1713
+ let userId: string | undefined
1714
+ if (userIdStr) {
1715
+ try {
1716
+ userId = userIdStr
1717
+ } catch (e) {
1718
+ console.warn('[SDK]: Failed to parse userId from localStorage:', e)
1719
+ }
1720
+ }
1721
+
1722
+ // Check if this will be a handoff flow (desktop device)
1723
+ const isHandoff = this.isDesktopDevice()
1724
+
1725
+ // Create signature document BEFORE signing (this also validates domain and template and starts countdown for handoff)
1726
+ const createResponse = await fetch(`${this.apiUrl}/api/signatures/create`, {
1727
+ method: 'POST',
1728
+ headers: { 'Content-Type': 'application/json' },
1729
+ body: JSON.stringify({
1730
+ appId: this.authAppId,
1731
+ baseUrl,
1732
+ endpoint: uriPath,
1733
+ method: apiCallStructure.method,
1734
+ keyId: keyId || '',
1735
+ userId: userId,
1736
+ apiCallStructure,
1737
+ signatureId,
1738
+ isHandoff // Flag to indicate handoff flow
1739
+ })
1740
+ })
1741
+
1742
+ if (createResponse.ok) {
1743
+ const createData = await createResponse.json()
1744
+ if (createData.success && createData.data?.signatureId) {
1745
+ console.log('[SDK]: Signature document created with signatureId:', createData.data.signatureId)
1746
+ } else {
1747
+ throw new Error('Failed to create signature document: ' + (createData.error || 'No signatureId returned'))
1748
+ }
1749
+ } else {
1750
+ const errorData = await createResponse.json().catch(() => ({}))
1751
+ throw new Error('Failed to create signature: ' + (errorData.error || 'Creation request failed'))
1752
+ }
1753
+
1754
+ // Now sign the payload (pass signatureId for desktop handoff)
1755
+ const signingResult = await this.signPayload(apiCallStructure, signatureId)
1756
+
1757
+ // Make the actual API call with signature and signatureId in header
1758
+ // Use keyId from signingResult (Device B's keyId for handoff, or localStorage keyId for mobile)
1759
+ console.log('[SDK]: 🔍 KeyId debug - signingResult.keyId:', signingResult.keyId, 'localStorage keyId:', keyId, 'keyId type:', typeof signingResult.keyId, 'empty?:', signingResult.keyId === '')
1760
+ const keyIdForAuth = signingResult.keyId || keyId || ''
1761
+ console.log('[SDK]: ✅ Final keyId for auth header:', keyIdForAuth, '(from', signingResult.keyId && signingResult.keyId !== '' ? 'Device B' : 'localStorage fallback', ')')
1762
+ const authHeaderValue = btoa(JSON.stringify({ signature: signingResult.signature, keyId: keyIdForAuth, signatureId }))
1763
+
1764
+ const response = await fetch(url, {
1765
+ ...options,
1766
+ headers: {
1767
+ ...options.headers,
1768
+ 'x-truly-auth': authHeaderValue
1769
+ }
1770
+ })
1771
+
1772
+ console.log('[SDK]: Request complete, status:', response.status)
1773
+
1774
+ return {
1775
+ response,
1776
+ signature: signingResult.signature,
1777
+ signatureId
1778
+ }
1779
+ } catch (error: any) {
1780
+ console.error('[SDK]: Signature creation error:', error)
1781
+ // Silently fail for countdown expiry - don't throw error
1782
+ if (error.message && error.message.includes('Countdown expired')) {
1783
+ // Return empty result to indicate silent failure
1784
+ return { response: null as any, signature: '', signatureId: signatureId || '' }
1785
+ }
1786
+ throw new Error('Signature creation failed: ' + (error.message || 'Unknown error'))
1787
+ }
1788
+ } else {
1789
+ throw new Error('authAppId is required for signature validation. Please configure authAppId in SDK initialization.')
1790
+ }
1791
+
1792
+ } catch (error: any) {
1793
+ console.error('[SDK]: fetchWithSignature error:', error)
1794
+ // Silently fail for countdown expiry - just reset state
1795
+ if (error.message && error.message.includes('Countdown expired')) {
1796
+ // Don't throw error, just silently reset - return empty result
1797
+ // signatureId is in scope from fetchWithSignature function
1798
+ const expiredSignatureId = signatureId || ''
1799
+ return { response: null as any, signature: '', signatureId: expiredSignatureId }
1800
+ }
1801
+ throw error
1802
+ }
1803
+ }
1804
+ }
1805
+