@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.
- package/dist/index.d.ts +2 -0
- package/dist/index.esm.js +9 -0
- package/dist/index.esm.js.map +1 -0
- package/dist/index.js +9 -0
- package/dist/index.js.map +1 -0
- package/dist/index.umd.js +9 -0
- package/dist/index.umd.js.map +1 -0
- package/dist/sdk/TrulyYouSDK.d.ts +77 -0
- package/dist/types.d.ts +36 -0
- package/package.json +43 -0
- package/rollup.config.js +58 -0
- package/src/index.ts +3 -0
- package/src/sdk/TrulyYouSDK.ts +1805 -0
- package/src/types.ts +40 -0
- package/tsconfig.json +20 -0
|
@@ -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
|
+
|