@polyguard/sdk 1.2.3 → 1.3.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/package.json CHANGED
@@ -1,21 +1,24 @@
1
1
  {
2
2
  "name": "@polyguard/sdk",
3
- "version": "1.2.3",
3
+ "version": "1.3.0",
4
4
  "type": "module",
5
- "main": "dist/sdk.js",
6
- "module": "dist/sdk.js",
7
- "browser": "dist/sdk.js",
5
+ "main": "dist/sdk.esm.js",
6
+ "module": "dist/sdk.esm.js",
7
+ "browser": "dist/sdk.esm.js",
8
8
  "exports": {
9
9
  ".": {
10
- "import": "./dist/sdk.js",
11
- "require": "./dist/sdk.js",
12
- "default": "./dist/sdk.js"
10
+ "import": "./dist/sdk.esm.js",
11
+ "require": "./dist/sdk.esm.js",
12
+ "default": "./dist/sdk.esm.js"
13
13
  }
14
14
  },
15
15
  "scripts": {
16
- "build": "esbuild src/index.js --bundle --format=esm --outfile=dist/sdk.js",
16
+ "build": "npm run build:esm && npm run build:iife",
17
+ "build:esm": "esbuild src/index.js --bundle --format=esm --outfile=dist/sdk.esm.js",
18
+ "build:iife": "esbuild src/browser.js --bundle --format=iife --global-name=Polyguard --outfile=dist/sdk.js",
17
19
  "prepare": "npm run build",
18
- "test": "echo \"Error: no test specified\" && exit 1",
20
+ "test": "vitest run",
21
+ "test:watch": "vitest",
19
22
  "regenerate-client": "bash scripts/regenerate-client.sh"
20
23
  },
21
24
  "keywords": [],
@@ -28,7 +31,9 @@
28
31
  "superagent": "^10.3.0"
29
32
  },
30
33
  "devDependencies": {
31
- "esbuild": "^0.25.0"
34
+ "esbuild": "^0.25.0",
35
+ "jsdom": "^28.0.0",
36
+ "vitest": "^4.0.18"
32
37
  },
33
38
  "repository": {
34
39
  "type": "git",
@@ -1,24 +1,8 @@
1
1
  import ReconnectingWebSocket from 'reconnecting-websocket';
2
2
  import * as PolyguardApi from './generated/src';
3
- import QRCode from 'qrcode';
4
-
5
- // Animated spinner SVG
6
- const LOADING_SPINNER = `<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200" viewBox="0 0 200 200" fill="none">
7
- <style>
8
- .spinner-circle {
9
- animation: spin 1s linear infinite;
10
- transform-origin: 100px 100px;
11
- }
12
- @keyframes spin {
13
- from { transform: rotate(0deg); }
14
- to { transform: rotate(360deg); }
15
- }
16
- </style>
17
- <circle cx="100" cy="100" r="80" stroke="#e0e0e0" stroke-width="8" fill="none" />
18
- <circle cx="100" cy="100" r="80" stroke="#7be7c2" stroke-width="8" fill="none"
19
- stroke-dasharray="251.2" stroke-dashoffset="188.4"
20
- class="spinner-circle" stroke-linecap="round" />
21
- </svg>`;
3
+ import { buildModal, showSpinner } from './ui.js';
4
+ import { fetchTicket } from './ticketService.js';
5
+ import { handleWebSocketMessage } from './messageHandler.js';
22
6
 
23
7
  // Implementation class for websocket integration
24
8
  export class PolyguardWebsocketClientImpl {
@@ -53,71 +37,35 @@ export class PolyguardWebsocketClientImpl {
53
37
  }
54
38
 
55
39
  buildModal() {
56
- const modal = document.createElement('div');
57
- modal.style.position = 'fixed';
58
- modal.style.top = '0';
59
- modal.style.left = '0';
60
- modal.style.width = '100vw';
61
- modal.style.height = '100vh';
62
- modal.style.background = 'rgba(0,0,0,0.45)';
63
- modal.style.display = 'flex';
64
- modal.style.alignItems = 'flex-start';
65
- modal.style.justifyContent = 'center';
66
- modal.style.overflowY = 'auto';
67
- modal.style.paddingTop = '24px';
68
- modal.innerHTML = `
69
- <div id="polyguard-modal-content" style="background: #fff; border-radius: 16px; box-shadow: 0 8px 32px rgba(0,0,0,0.18); padding: 32px 24px 24px 24px; max-width: 340px; width: 100%; text-align: center; position: relative; font-family: 'Inter', 'Helvetica', 'Arial', sans-serif; margin: 0 auto; box-sizing: border-box;">
70
- <button id="polyguard-modal-close" style="position: absolute; top: 12px; right: 12px; background: none; border: none; font-size: 22px; cursor: pointer; z-index: 2;">&times;</button>
71
- <h2 style="margin-top: 0; font-size: 1.5rem; font-weight: 700;">Quick Identity Verification</h2>
72
- <div style="font-size: 15px; color: #888; margin-bottom: 12px; font-weight: 500;">Powered by Polyguard</div>
73
- <div id="polyguard-qr" style="width: 200px; height: 200px; margin: 0 auto 16px auto; display: flex; align-items: center; justify-content: center; background: #f4f8fb; border-radius: 12px;"></div>
74
- <div style="margin-bottom: 12px; font-weight: 600;">Scan this QR code to verify your identity.</div>
75
- <ul style="text-align: left; margin: 0 0 16px 0; padding-left: 20px; font-size: 14px; color: #444;">
76
- <li>We use the Polyguard service to verify your identity.</li>
77
- <li>If you do not have the Polyguard app, the QR code will redirect you to download it from the App Store or Google Play.</li>
78
- <li>Your credentials remain private on your device.</li>
79
- </ul>
80
- <div id="polyguard-error" style="color: #b31d28; font-size: 14px; margin-bottom: 8px; display: none;"></div>
81
- <button id="polyguard-modal-cancel" style="background: #7be7c2; color: #222; font-weight: 600; border-radius: 8px; border: none; padding: 10px 32px; font-size: 16px; cursor: pointer; margin-top: 8px; width: 100%; max-width: 240px;">Cancel</button>
82
- </div>
83
- `;
84
- return modal;
40
+ return buildModal();
85
41
  }
86
42
 
87
43
  async verify(target = null, rawJwt = false) {
88
- // Only websocket integration is supported for modal
89
44
  let modal = null;
90
45
  let qrDiv = null;
91
46
  let isTargetMode = false;
92
-
47
+
93
48
  if (target) {
94
- // Target mode: render QR code in specified element
95
49
  isTargetMode = true;
96
50
  qrDiv = document.getElementById(target);
97
51
  if (!qrDiv) {
98
52
  throw new Error(`Target element with ID '${target}' not found`);
99
53
  }
100
54
  } else {
101
- // Modal mode: create modal DOM
102
- modal = this.buildModal();
55
+ modal = buildModal();
103
56
  }
104
-
57
+
105
58
  if (!isTargetMode) {
106
- // Show modal immediately with spinner
107
59
  document.body.appendChild(modal);
108
-
109
- // Initialize QR div with spinner
110
60
  qrDiv = modal.querySelector('#polyguard-qr');
111
61
  }
112
-
62
+
113
63
  if (qrDiv) {
114
- qrDiv.innerHTML = LOADING_SPINNER;
64
+ showSpinner(qrDiv);
115
65
  }
116
-
117
- // Helper to cleanup modal or target
66
+
118
67
  function cleanup() {
119
68
  if (isTargetMode) {
120
- // Clear target element content
121
69
  if (qrDiv) {
122
70
  qrDiv.innerHTML = '';
123
71
  }
@@ -125,28 +73,27 @@ export class PolyguardWebsocketClientImpl {
125
73
  modal.parentNode.removeChild(modal);
126
74
  }
127
75
  }
128
- // Promise for JWT
76
+
129
77
  return new Promise(async (resolve, reject) => {
130
78
  let ws = null;
131
79
  let closed = false;
132
-
80
+
133
81
  function returnError(msg, score = "OFFLINE") {
134
- if (isTargetMode) {
135
- // In target mode, just log the error and resolve with error data
136
- console.error('Polyguard Error:', msg);
82
+ if (isTargetMode) {
83
+ console.error('Polyguard Error:', msg);
84
+ cleanup();
85
+ resolve({presence: { score: score, msg: msg }});
86
+ } else {
87
+ const errDiv = modal.querySelector('#polyguard-error');
88
+ if (errDiv) {
89
+ errDiv.textContent = msg;
90
+ errDiv.style.display = 'block';
91
+ }
92
+ setTimeout(() => {
137
93
  cleanup();
138
94
  resolve({presence: { score: score, msg: msg }});
139
- } else {
140
- const errDiv = modal.querySelector('#polyguard-error');
141
- if (errDiv) {
142
- errDiv.textContent = msg;
143
- errDiv.style.display = 'block';
144
- }
145
- setTimeout(() => {
146
- cleanup();
147
- resolve({presence: { score: score, msg: msg }});
148
- }, 1250);
149
- }
95
+ }, 1250);
96
+ }
150
97
  }
151
98
  function clearError() {
152
99
  if (!isTargetMode) {
@@ -154,42 +101,30 @@ export class PolyguardWebsocketClientImpl {
154
101
  if (errDiv) errDiv.style.display = 'none';
155
102
  }
156
103
  }
157
- // Close/cancel handler
158
104
  function handleClose() {
159
105
  closed = true;
160
106
  if (ws) ws.close();
161
107
  cleanup();
162
108
  reject(new Error('User cancelled'));
163
109
  }
164
-
165
- // Set up close/cancel handlers (only in modal mode)
110
+
166
111
  if (!isTargetMode) {
167
112
  modal.querySelector('#polyguard-modal-close').onclick = handleClose;
168
113
  modal.querySelector('#polyguard-modal-cancel').onclick = handleClose;
169
114
  }
170
-
171
- // Start ticket/ws flow
115
+
172
116
  try {
173
117
  clearError();
174
118
  const wsProtocol = window.location.protocol === 'https:' ? 'wss' : 'ws';
175
- const ticketUrl = this.link_uuid
176
- ? `https://${this.apiServer}/v2/ticket/${this.appId}/${this.link_uuid}`
177
- : `https://${this.apiServer}/v2/ticket/${this.appId}`;
178
- const ticketRes = await fetch(ticketUrl, {
179
- method: 'POST',
180
- headers: { 'Content-Type': 'application/json' },
181
- body: JSON.stringify({ requiredProofs: this.requiredProofs, scanType: this.scanType }),
119
+
120
+ const newTicket = await fetchTicket({
121
+ apiServer: this.apiServer,
122
+ appId: this.appId,
123
+ link_uuid: this.link_uuid,
124
+ requiredProofs: this.requiredProofs,
125
+ scanType: this.scanType,
182
126
  });
183
- if (!ticketRes.ok) {
184
- returnError('Failed to get ticket');
185
- return;
186
- }
187
- const ticketData = await ticketRes.json();
188
- const newTicket = ticketData.ticket;
189
- if (!newTicket) {
190
- returnError('No ticket returned from server');
191
- return;
192
- }
127
+
193
128
  const wsUrl = `${wsProtocol}://${this.apiServer}/v2/realtime/${newTicket}`;
194
129
  const options = {
195
130
  maxRetries: 10,
@@ -199,86 +134,10 @@ export class PolyguardWebsocketClientImpl {
199
134
  reconnectionDelayGrowFactor: 1.5,
200
135
  };
201
136
  ws = new ReconnectingWebSocket(wsUrl, [], options);
202
- ws.addEventListener('message', (event) => {
203
- try {
204
- const data = JSON.parse(event.data);
205
- // --- BEGIN: Backend-initiated latency measurement ---
206
- if (data && data.type === 'ping' && typeof data.seq !== 'undefined') {
207
- ws.send(JSON.stringify({ type: 'pong', seq: data.seq }));
208
- return;
209
- }
210
- // --- END: Backend-initiated latency measurement ---
211
- if (data && data.url) {
212
- window.location.assign(data.url);
213
- return;
214
- } else if (data && data.qr_url) {
215
- // Replace spinner with QR code content
216
-
217
- const pcre = data.qr_url.match(/pcre=([^&]*)/);
218
- console.log('pcre', pcre);
219
- if (pcre) {
220
- ws.send(JSON.stringify({ type: 'pong', seq: pcre[1] }));
221
- }
222
-
223
- if (!qrDiv) return;
224
-
225
- const isMobile = /Mobi|Android/i.test(navigator.userAgent);
226
137
 
227
- if (isMobile) {
228
- // For mobile, display a button to open the app
229
- qrDiv.innerHTML = `<button id="polyguard-open-app-button" style="background: #7be7c2; color: #222; font-weight: 600; border-radius: 8px; border: none; padding: 10px 32px; font-size: 16px; cursor: pointer;">Open Polyguard App</button>`;
230
- qrDiv.style.background = 'transparent';
231
- qrDiv.querySelector('#polyguard-open-app-button').onclick = () => window.location.assign(data.qr_url);
138
+ const ctx = { ws, qrDiv, isTargetMode, modal, cleanup, returnError, clearError, resolve, rawJwt };
139
+ ws.addEventListener('message', (event) => handleWebSocketMessage(event, ctx));
232
140
 
233
- // Update surrounding text only in modal mode
234
- if (!isTargetMode) {
235
- const instructionText = qrDiv.nextElementSibling;
236
- if (instructionText) {
237
- instructionText.textContent = 'Tap the button to verify with the Polyguard app.';
238
- }
239
- const instructionList = instructionText.nextElementSibling;
240
- if (instructionList && instructionList.children[1]) {
241
- instructionList.children[1].textContent = 'If you do not have the Polyguard app, you will be redirected to download it.';
242
- }
243
- }
244
- } else {
245
- const startTime = Date.now();
246
- console.log('time before qr code', startTime);
247
- // For desktop, display the QR code
248
- QRCode.toString(data.qr_url, { type: 'svg' }, (err, svg) => {
249
- if (!err) qrDiv.innerHTML = svg;
250
- });
251
- console.log('time to generate qr code', Date.now() - startTime);
252
- }
253
- return;
254
- } else if (data && data.jwt) {
255
- cleanup();
256
- ws.close();
257
- resolve(rawJwt ? data : data.jwt);
258
- return;
259
- } else if (data && data.status) {
260
- // ignore
261
- // generate a spinner in svg and set the qr code to it
262
- if (!qrDiv) return;
263
- qrDiv.innerHTML = LOADING_SPINNER;
264
- return;
265
-
266
- } else if (data && data.error) {
267
- returnError(data.error);
268
- ws.close();
269
- return;
270
- } else {
271
- console.error('Unknown message type from server', data);
272
- returnError(`Unknown message type from server`);
273
- ws.close();
274
- return;
275
- }
276
- } catch (e) {
277
- console.error('Invalid message format from server', e);
278
- returnError('Invalid message format from server');
279
- // ws.close();
280
- }
281
- });
282
141
  ws.addEventListener('error', () => {
283
142
  console.error('WebSocket error');
284
143
  returnError('WebSocket error');
@@ -287,7 +146,9 @@ export class PolyguardWebsocketClientImpl {
287
146
  if (!closed) cleanup();
288
147
  });
289
148
  } catch (err) {
290
- returnError('Failed to connect to WebSocket');
149
+ returnError(err.message === 'Failed to get ticket' || err.message === 'No ticket returned from server'
150
+ ? err.message
151
+ : 'Failed to connect to WebSocket');
291
152
  }
292
153
  });
293
154
  }
@@ -0,0 +1,65 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { DEFAULT_PARAMS } from './helpers/fixtures.js';
3
+
4
+ // Mock the WebSocket impl so the facade tests never touch real internals
5
+ vi.mock('../PolyguardWebsocketClientImpl', () => {
6
+ class FakeImpl {
7
+ constructor(params = {}) {
8
+ this.appId = params.appId;
9
+ this.apiServer = params.apiServer;
10
+ this.integrationType = 'websocket';
11
+ this.blockingApi = { fake: true };
12
+ this.callsApi = { fake: true };
13
+ }
14
+ async verify(...args) {
15
+ return { args, result: 'fake-jwt' };
16
+ }
17
+ }
18
+ return { PolyguardWebsocketClientImpl: FakeImpl };
19
+ });
20
+
21
+ // Import after mock is set up
22
+ const { PolyguardClient } = await import('../PolyguardClient.js');
23
+
24
+ describe('PolyguardClient', () => {
25
+ beforeEach(() => {
26
+ vi.clearAllMocks();
27
+ });
28
+
29
+ it('defaults to websocket implementation', () => {
30
+ const client = new PolyguardClient(DEFAULT_PARAMS);
31
+ expect(client._impl).toBeDefined();
32
+ expect(client._impl.integrationType).toBe('websocket');
33
+ });
34
+
35
+ it('explicit integrationType websocket works the same', () => {
36
+ const client = new PolyguardClient({ ...DEFAULT_PARAMS, integrationType: 'websocket' });
37
+ expect(client._impl.integrationType).toBe('websocket');
38
+ });
39
+
40
+ it('copies properties from impl to facade', () => {
41
+ const client = new PolyguardClient(DEFAULT_PARAMS);
42
+ expect(client.appId).toBe(DEFAULT_PARAMS.appId);
43
+ expect(client.apiServer).toBe(DEFAULT_PARAMS.apiServer);
44
+ expect(client.integrationType).toBe('websocket');
45
+ expect(client.blockingApi).toEqual({ fake: true });
46
+ expect(client.callsApi).toEqual({ fake: true });
47
+ });
48
+
49
+ it('verify() delegates to _impl.verify() with all args forwarded', async () => {
50
+ const client = new PolyguardClient(DEFAULT_PARAMS);
51
+ const spy = vi.spyOn(client._impl, 'verify');
52
+ await client.verify('my-target', true);
53
+ expect(spy).toHaveBeenCalledWith('my-target', true);
54
+ });
55
+
56
+ it('verify() returns the impl result', async () => {
57
+ const client = new PolyguardClient(DEFAULT_PARAMS);
58
+ const result = await client.verify('t', false);
59
+ expect(result).toEqual({ args: ['t', false], result: 'fake-jwt' });
60
+ });
61
+
62
+ it('constructor with no params does not throw', () => {
63
+ expect(() => new PolyguardClient()).not.toThrow();
64
+ });
65
+ });