@polyguard/sdk 1.4.1 → 1.5.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/sdk.js CHANGED
@@ -10953,8 +10953,9 @@ var Polyguard = (() => {
10953
10953
  }
10954
10954
 
10955
10955
  // src/ticketService.js
10956
- async function fetchTicket({ apiServer, appId, link_uuid, requiredProofs, scanType }) {
10957
- const ticketUrl = link_uuid ? `https://${apiServer}/v2/ticket/${appId}/${link_uuid}` : `https://${apiServer}/v2/ticket/${appId}`;
10956
+ async function fetchTicket({ apiServer, proxyUrl, appId, link_uuid, requiredProofs, scanType }) {
10957
+ const base = proxyUrl ? proxyUrl.replace(/\/$/, "") : `https://${apiServer}`;
10958
+ const ticketUrl = link_uuid ? `${base}/v2/ticket/${appId}/${link_uuid}` : `${base}/v2/ticket/${appId}`;
10958
10959
  const ticketRes = await fetch(ticketUrl, {
10959
10960
  method: "POST",
10960
10961
  headers: { "Content-Type": "application/json" },
@@ -11017,6 +11018,7 @@ var Polyguard = (() => {
11017
11018
  const { sidebarUrl, link_uuid } = ctx;
11018
11019
  if (sidebarUrl && redirectUrl) {
11019
11020
  const hasMsftSession = document.cookie.includes("pg_msft_session");
11021
+ ctx.markClosed();
11020
11022
  cleanup();
11021
11023
  ws.close();
11022
11024
  if (hasMsftSession) {
@@ -11065,15 +11067,24 @@ var Polyguard = (() => {
11065
11067
  var PolyguardWebsocketClientImpl = class {
11066
11068
  constructor(params = {}) {
11067
11069
  this.apiClient = new ApiClient_default();
11068
- const { apiKey, baseUrl, appId, apiServer, requiredProofs = ["Full Name"], scanType = "single", redirectUrl, callbackUrl, cookieName, link_uuid, sidebarUrl } = params;
11070
+ const { apiKey, baseUrl, appId, apiServer, proxyUrl, iKnowThisIsInsecure, requiredProofs = ["Full Name"], scanType = "single", redirectUrl, callbackUrl, cookieName, link_uuid, sidebarUrl } = params;
11069
11071
  this.sidebarUrl = sidebarUrl;
11070
- if (baseUrl) {
11072
+ if (apiKey && typeof window !== "undefined" && iKnowThisIsInsecure !== true) {
11073
+ throw new Error(
11074
+ "apiKey is forbidden in browser code; use proxyUrl with a server-side proxy (see @polyguard/sdk/server). Set iKnowThisIsInsecure: true to bypass."
11075
+ );
11076
+ }
11077
+ if (proxyUrl) {
11078
+ this.apiClient.basePath = proxyUrl.replace(/\/$/, "");
11079
+ } else if (baseUrl) {
11071
11080
  this.apiClient.basePath = baseUrl;
11072
11081
  }
11073
11082
  if (apiKey) {
11074
11083
  if (this.apiClient.authentications["bearerAuth"]) {
11075
11084
  this.apiClient.authentications["bearerAuth"].apiKey = apiKey;
11076
11085
  }
11086
+ this.apiClient.defaultHeaders = this.apiClient.defaultHeaders || {};
11087
+ this.apiClient.defaultHeaders["x-pg-api-key"] = apiKey;
11077
11088
  }
11078
11089
  for (const key in src_exports) {
11079
11090
  if (key.endsWith("Api")) {
@@ -11084,6 +11095,7 @@ var Polyguard = (() => {
11084
11095
  this.integrationType = "websocket";
11085
11096
  this.appId = appId;
11086
11097
  this.apiServer = apiServer;
11098
+ this.proxyUrl = proxyUrl;
11087
11099
  this.requiredProofs = requiredProofs;
11088
11100
  this.scanType = scanType;
11089
11101
  this.redirectUrl = redirectUrl;
@@ -11164,6 +11176,7 @@ var Polyguard = (() => {
11164
11176
  const wsProtocol = window.location.protocol === "https:" ? "wss" : "ws";
11165
11177
  const newTicket = await fetchTicket({
11166
11178
  apiServer: this.apiServer,
11179
+ proxyUrl: this.proxyUrl,
11167
11180
  appId: this.appId,
11168
11181
  link_uuid: this.link_uuid,
11169
11182
  requiredProofs: this.requiredProofs,
@@ -11178,7 +11191,10 @@ var Polyguard = (() => {
11178
11191
  reconnectionDelayGrowFactor: 1.5
11179
11192
  };
11180
11193
  ws = new reconnecting_websocket_mjs_default(wsUrl, [], options);
11181
- const ctx = { ws, qrDiv, isTargetMode, modal, cleanup, returnError, clearError, resolve, rawJwt, sidebarUrl: this.sidebarUrl, link_uuid: this.link_uuid };
11194
+ const markClosed = () => {
11195
+ closed = true;
11196
+ };
11197
+ const ctx = { ws, qrDiv, isTargetMode, modal, cleanup, markClosed, returnError, clearError, resolve, rawJwt, sidebarUrl: this.sidebarUrl, link_uuid: this.link_uuid };
11182
11198
  ws.addEventListener("message", (event) => handleWebSocketMessage(event, ctx));
11183
11199
  ws.addEventListener("error", () => {
11184
11200
  console.error("WebSocket error");
@@ -0,0 +1,83 @@
1
+ // src/server.js
2
+ var DEFAULT_ALLOWED_PATHS = [
3
+ "POST /v2/ticket/",
4
+ "PUT /link/",
5
+ "GET /v2/preview/"
6
+ ];
7
+ var HOP_BY_HOP = /* @__PURE__ */ new Set([
8
+ "connection",
9
+ "keep-alive",
10
+ "proxy-authenticate",
11
+ "proxy-authorization",
12
+ "te",
13
+ "trailer",
14
+ "transfer-encoding",
15
+ "upgrade"
16
+ ]);
17
+ function isAllowed(method, pathname, allowedPaths) {
18
+ const upper = method.toUpperCase();
19
+ for (const rule of allowedPaths) {
20
+ const idx = rule.indexOf(" ");
21
+ if (idx < 0) continue;
22
+ const ruleMethod = rule.slice(0, idx).toUpperCase();
23
+ const rulePrefix = rule.slice(idx + 1);
24
+ if (ruleMethod === upper && pathname.startsWith(rulePrefix)) {
25
+ return true;
26
+ }
27
+ }
28
+ return false;
29
+ }
30
+ function createProxyHandler(options = {}) {
31
+ const { apiKey, apiServer = "api.polyguard.ai", allowedPaths = DEFAULT_ALLOWED_PATHS, pathPrefix = "/api/polyguard" } = options;
32
+ if (!apiKey) {
33
+ throw new Error("createProxyHandler: apiKey is required");
34
+ }
35
+ if (!apiServer) {
36
+ throw new Error("createProxyHandler: apiServer is required");
37
+ }
38
+ return async function polyguardProxy(request) {
39
+ const url = new URL(request.url);
40
+ let pathname = url.pathname;
41
+ if (pathPrefix && pathname.startsWith(pathPrefix)) {
42
+ pathname = pathname.slice(pathPrefix.length) || "/";
43
+ }
44
+ if (!isAllowed(request.method, pathname, allowedPaths)) {
45
+ return new Response("Not Found", { status: 404 });
46
+ }
47
+ const targetUrl = `https://${apiServer}${pathname}${url.search}`;
48
+ const forwardHeaders = new Headers();
49
+ for (const [name, value] of request.headers) {
50
+ const lower = name.toLowerCase();
51
+ if (lower === "x-pg-api-key" || lower === "authorization") continue;
52
+ if (HOP_BY_HOP.has(lower)) continue;
53
+ if (lower === "host") continue;
54
+ forwardHeaders.set(name, value);
55
+ }
56
+ forwardHeaders.set("x-pg-api-key", apiKey);
57
+ const init = {
58
+ method: request.method,
59
+ headers: forwardHeaders,
60
+ redirect: "manual"
61
+ };
62
+ if (request.method !== "GET" && request.method !== "HEAD") {
63
+ init.body = request.body;
64
+ init.duplex = "half";
65
+ }
66
+ const upstream = await fetch(targetUrl, init);
67
+ const responseHeaders = new Headers();
68
+ for (const [name, value] of upstream.headers) {
69
+ const lower = name.toLowerCase();
70
+ if (HOP_BY_HOP.has(lower)) continue;
71
+ if (lower === "set-cookie") continue;
72
+ responseHeaders.set(name, value);
73
+ }
74
+ return new Response(upstream.body, {
75
+ status: upstream.status,
76
+ statusText: upstream.statusText,
77
+ headers: responseHeaders
78
+ });
79
+ };
80
+ }
81
+ export {
82
+ createProxyHandler
83
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@polyguard/sdk",
3
- "version": "1.4.1",
3
+ "version": "1.5.0",
4
4
  "type": "module",
5
5
  "main": "dist/sdk.esm.js",
6
6
  "module": "dist/sdk.esm.js",
@@ -10,15 +10,21 @@
10
10
  "import": "./dist/sdk.esm.js",
11
11
  "require": "./dist/sdk.esm.js",
12
12
  "default": "./dist/sdk.esm.js"
13
+ },
14
+ "./server": {
15
+ "import": "./dist/server.esm.js",
16
+ "default": "./dist/server.esm.js"
13
17
  }
14
18
  },
15
19
  "scripts": {
16
- "build": "npm run build:esm && npm run build:iife",
20
+ "build": "npm run build:esm && npm run build:iife && npm run build:server",
17
21
  "build:esm": "esbuild src/index.js --bundle --format=esm --outfile=dist/sdk.esm.js",
18
22
  "build:iife": "esbuild src/browser.js --bundle --format=iife --global-name=Polyguard --outfile=dist/sdk.js",
23
+ "build:server": "esbuild src/server.js --bundle --format=esm --platform=neutral --outfile=dist/server.esm.js",
19
24
  "prepare": "npm run build",
20
25
  "test": "vitest run",
21
26
  "test:watch": "vitest",
27
+ "test:coverage": "vitest run --coverage",
22
28
  "regenerate-client": "bash scripts/regenerate-client.sh"
23
29
  },
24
30
  "keywords": [],
@@ -31,6 +37,7 @@
31
37
  "superagent": "^10.3.0"
32
38
  },
33
39
  "devDependencies": {
40
+ "@vitest/coverage-v8": "^4.1.6",
34
41
  "esbuild": "^0.25.0",
35
42
  "jsdom": "^28.0.0",
36
43
  "vitest": "^4.0.18"
@@ -8,15 +8,28 @@ import { handleWebSocketMessage } from './messageHandler.js';
8
8
  export class PolyguardWebsocketClientImpl {
9
9
  constructor(params = {}) {
10
10
  this.apiClient = new PolyguardApi.ApiClient();
11
- const { apiKey, baseUrl, appId, apiServer, requiredProofs = ['Full Name'], scanType = 'single', redirectUrl, callbackUrl, cookieName, link_uuid, sidebarUrl } = params;
11
+ const { apiKey, baseUrl, appId, apiServer, proxyUrl, iKnowThisIsInsecure, requiredProofs = ['Full Name'], scanType = 'single', redirectUrl, callbackUrl, cookieName, link_uuid, sidebarUrl } = params;
12
12
  this.sidebarUrl = sidebarUrl;
13
- if (baseUrl) {
13
+
14
+ if (apiKey && typeof window !== 'undefined' && iKnowThisIsInsecure !== true) {
15
+ throw new Error(
16
+ 'apiKey is forbidden in browser code; use proxyUrl with a server-side proxy (see @polyguard/sdk/server). ' +
17
+ 'Set iKnowThisIsInsecure: true to bypass.'
18
+ );
19
+ }
20
+
21
+ if (proxyUrl) {
22
+ this.apiClient.basePath = proxyUrl.replace(/\/$/, '');
23
+ } else if (baseUrl) {
14
24
  this.apiClient.basePath = baseUrl;
15
25
  }
26
+
16
27
  if (apiKey) {
17
28
  if (this.apiClient.authentications['bearerAuth']) {
18
29
  this.apiClient.authentications['bearerAuth'].apiKey = apiKey;
19
30
  }
31
+ this.apiClient.defaultHeaders = this.apiClient.defaultHeaders || {};
32
+ this.apiClient.defaultHeaders['x-pg-api-key'] = apiKey;
20
33
  }
21
34
  // Instantiate all API services
22
35
  for (const key in PolyguardApi) {
@@ -29,6 +42,7 @@ export class PolyguardWebsocketClientImpl {
29
42
  this.integrationType = 'websocket';
30
43
  this.appId = appId;
31
44
  this.apiServer = apiServer;
45
+ this.proxyUrl = proxyUrl;
32
46
  this.requiredProofs = requiredProofs;
33
47
  this.scanType = scanType;
34
48
  this.redirectUrl = redirectUrl;
@@ -120,6 +134,7 @@ export class PolyguardWebsocketClientImpl {
120
134
 
121
135
  const newTicket = await fetchTicket({
122
136
  apiServer: this.apiServer,
137
+ proxyUrl: this.proxyUrl,
123
138
  appId: this.appId,
124
139
  link_uuid: this.link_uuid,
125
140
  requiredProofs: this.requiredProofs,
@@ -136,7 +151,8 @@ export class PolyguardWebsocketClientImpl {
136
151
  };
137
152
  ws = new ReconnectingWebSocket(wsUrl, [], options);
138
153
 
139
- const ctx = { ws, qrDiv, isTargetMode, modal, cleanup, returnError, clearError, resolve, rawJwt, sidebarUrl: this.sidebarUrl, link_uuid: this.link_uuid };
154
+ const markClosed = () => { closed = true; };
155
+ const ctx = { ws, qrDiv, isTargetMode, modal, cleanup, markClosed, returnError, clearError, resolve, rawJwt, sidebarUrl: this.sidebarUrl, link_uuid: this.link_uuid };
140
156
  ws.addEventListener('message', (event) => handleWebSocketMessage(event, ctx));
141
157
 
142
158
  ws.addEventListener('error', () => {
@@ -162,11 +162,35 @@ describe('PolyguardWebsocketClientImpl', () => {
162
162
  expect(client.apiClient.basePath).toBe('https://custom.api');
163
163
  });
164
164
 
165
- it('sets apiKey when provided', () => {
166
- const client = new PolyguardWebsocketClientImpl({ ...DEFAULT_PARAMS, apiKey: 'my-key' });
165
+ it('throws when apiKey is provided in browser without iKnowThisIsInsecure flag', () => {
166
+ expect(() => new PolyguardWebsocketClientImpl({ ...DEFAULT_PARAMS, apiKey: 'my-key' })).toThrow(/apiKey is forbidden in browser code/);
167
+ });
168
+
169
+ it('accepts apiKey in browser with iKnowThisIsInsecure: true', () => {
170
+ const client = new PolyguardWebsocketClientImpl({ ...DEFAULT_PARAMS, apiKey: 'my-key', iKnowThisIsInsecure: true });
167
171
  expect(client.apiClient.authentications.bearerAuth.apiKey).toBe('my-key');
168
172
  });
169
173
 
174
+ it('also sets x-pg-api-key default header when apiKey is accepted', () => {
175
+ const client = new PolyguardWebsocketClientImpl({ ...DEFAULT_PARAMS, apiKey: 'my-key', iKnowThisIsInsecure: true });
176
+ expect(client.apiClient.defaultHeaders['x-pg-api-key']).toBe('my-key');
177
+ });
178
+
179
+ it('sets basePath from proxyUrl and strips trailing slash', () => {
180
+ const client = new PolyguardWebsocketClientImpl({ ...DEFAULT_PARAMS, proxyUrl: '/api/polyguard/' });
181
+ expect(client.apiClient.basePath).toBe('/api/polyguard');
182
+ });
183
+
184
+ it('proxyUrl takes precedence over baseUrl', () => {
185
+ const client = new PolyguardWebsocketClientImpl({ ...DEFAULT_PARAMS, proxyUrl: '/api/polyguard', baseUrl: 'https://other.api' });
186
+ expect(client.apiClient.basePath).toBe('/api/polyguard');
187
+ });
188
+
189
+ it('stores proxyUrl on the instance', () => {
190
+ const client = new PolyguardWebsocketClientImpl({ ...DEFAULT_PARAMS, proxyUrl: '/api/polyguard' });
191
+ expect(client.proxyUrl).toBe('/api/polyguard');
192
+ });
193
+
170
194
  it('sets integrationType to websocket', () => {
171
195
  const client = new PolyguardWebsocketClientImpl(DEFAULT_PARAMS);
172
196
  expect(client.integrationType).toBe('websocket');
@@ -255,6 +279,25 @@ describe('PolyguardWebsocketClientImpl', () => {
255
279
  await promise.catch(() => {});
256
280
  });
257
281
 
282
+ it('builds ticket URL through proxyUrl when set, bypassing apiServer', async () => {
283
+ const fetchMock = stubFetch();
284
+ const client = new PolyguardWebsocketClientImpl({ ...DEFAULT_PARAMS, proxyUrl: '/api/polyguard' });
285
+ const { promise, ws } = await startVerifyAndGetWs(client);
286
+ const url = fetchMock.mock.calls[0][0];
287
+ expect(url).toBe(`/api/polyguard/v2/ticket/${DEFAULT_PARAMS.appId}`);
288
+ if (ws) ws.simulateMessage({ jwt: 'cleanup' });
289
+ await promise.catch(() => {});
290
+ });
291
+
292
+ it('still uses apiServer for WebSocket upgrade even when proxyUrl is set', async () => {
293
+ stubFetch();
294
+ const client = new PolyguardWebsocketClientImpl({ ...DEFAULT_PARAMS, proxyUrl: '/api/polyguard' });
295
+ const { promise, ws } = await startVerifyAndGetWs(client);
296
+ expect(ws.url).toBe(`wss://${DEFAULT_PARAMS.apiServer}/v2/realtime/test-ticket-123`);
297
+ ws.simulateMessage({ jwt: 'cleanup' });
298
+ await promise.catch(() => {});
299
+ });
300
+
258
301
  it('sends POST with requiredProofs and scanType in body', async () => {
259
302
  const fetchMock = stubFetch();
260
303
  const client = new PolyguardWebsocketClientImpl(DEFAULT_PARAMS);
@@ -0,0 +1,148 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { createProxyHandler } from '../server.js';
3
+
4
+ function makeRequest(method, url, { headers = {}, body = null } = {}) {
5
+ const init = { method, headers };
6
+ if (body !== null) init.body = typeof body === 'string' ? body : JSON.stringify(body);
7
+ return new Request(url, init);
8
+ }
9
+
10
+ function stubFetch(responseOpts = {}) {
11
+ const { status = 200, body = '{"ok":true}', headers = { 'content-type': 'application/json' } } = responseOpts;
12
+ const mock = vi.fn().mockImplementation(async () => new Response(body, { status, headers }));
13
+ vi.stubGlobal('fetch', mock);
14
+ return mock;
15
+ }
16
+
17
+ describe('createProxyHandler', () => {
18
+ beforeEach(() => {
19
+ vi.restoreAllMocks();
20
+ });
21
+
22
+ it('throws when apiKey is missing', () => {
23
+ expect(() => createProxyHandler({ apiServer: 'api.polyguard.ai' })).toThrow(/apiKey is required/);
24
+ });
25
+
26
+ it('throws when apiServer is falsy', () => {
27
+ expect(() => createProxyHandler({ apiKey: 'pgk_live_x.y', apiServer: '' })).toThrow(/apiServer is required/);
28
+ });
29
+
30
+ it('returns 404 for paths not in the allowlist', async () => {
31
+ stubFetch();
32
+ const handler = createProxyHandler({ apiKey: 'pgk_live_x.y', apiServer: 'api.polyguard.ai' });
33
+ const req = makeRequest('GET', 'http://localhost/api/polyguard/account/1/apps/3/api-keys');
34
+ const res = await handler(req);
35
+ expect(res.status).toBe(404);
36
+ });
37
+
38
+ it('returns 404 for an allowlisted prefix used with the wrong HTTP method', async () => {
39
+ stubFetch();
40
+ const handler = createProxyHandler({ apiKey: 'pgk_live_x.y', apiServer: 'api.polyguard.ai' });
41
+ const req = makeRequest('GET', 'http://localhost/api/polyguard/v2/ticket/3');
42
+ const res = await handler(req);
43
+ expect(res.status).toBe(404);
44
+ });
45
+
46
+ it('forwards POST /v2/ticket/* with x-pg-api-key injected', async () => {
47
+ const fetchMock = stubFetch();
48
+ const handler = createProxyHandler({ apiKey: 'pgk_live_x.y', apiServer: 'api.polyguard.ai' });
49
+ const req = makeRequest('POST', 'http://localhost/api/polyguard/v2/ticket/3', {
50
+ headers: { 'content-type': 'application/json' },
51
+ body: { requiredProofs: ['name'], scanType: 'single' },
52
+ });
53
+ const res = await handler(req);
54
+ expect(res.status).toBe(200);
55
+ expect(fetchMock).toHaveBeenCalledOnce();
56
+ const [forwardedUrl, init] = fetchMock.mock.calls[0];
57
+ expect(forwardedUrl).toBe('https://api.polyguard.ai/v2/ticket/3');
58
+ expect(init.method).toBe('POST');
59
+ const headers = new Headers(init.headers);
60
+ expect(headers.get('x-pg-api-key')).toBe('pgk_live_x.y');
61
+ });
62
+
63
+ it('strips client-supplied x-pg-api-key before injecting', async () => {
64
+ const fetchMock = stubFetch();
65
+ const handler = createProxyHandler({ apiKey: 'pgk_live_real.secret', apiServer: 'api.polyguard.ai' });
66
+ const req = makeRequest('POST', 'http://localhost/api/polyguard/v2/ticket/3', {
67
+ headers: { 'x-pg-api-key': 'pgk_live_attacker.smuggled', 'content-type': 'application/json' },
68
+ body: {},
69
+ });
70
+ await handler(req);
71
+ const init = fetchMock.mock.calls[0][1];
72
+ const headers = new Headers(init.headers);
73
+ expect(headers.get('x-pg-api-key')).toBe('pgk_live_real.secret');
74
+ });
75
+
76
+ it('strips client-supplied authorization header', async () => {
77
+ const fetchMock = stubFetch();
78
+ const handler = createProxyHandler({ apiKey: 'pgk_live_x.y', apiServer: 'api.polyguard.ai' });
79
+ const req = makeRequest('POST', 'http://localhost/api/polyguard/v2/ticket/3', {
80
+ headers: { authorization: 'Bearer pgk_live_attacker.smuggled', 'content-type': 'application/json' },
81
+ body: {},
82
+ });
83
+ await handler(req);
84
+ const init = fetchMock.mock.calls[0][1];
85
+ const headers = new Headers(init.headers);
86
+ expect(headers.get('authorization')).toBeNull();
87
+ });
88
+
89
+ it('preserves query string when forwarding', async () => {
90
+ const fetchMock = stubFetch();
91
+ const handler = createProxyHandler({ apiKey: 'pgk_live_x.y', apiServer: 'api.polyguard.ai' });
92
+ const req = makeRequest('GET', 'http://localhost/api/polyguard/v2/preview/3/link-abc?format=svg');
93
+ await handler(req);
94
+ expect(fetchMock.mock.calls[0][0]).toBe('https://api.polyguard.ai/v2/preview/3/link-abc?format=svg');
95
+ });
96
+
97
+ it('forwards PUT /link/* (link creation)', async () => {
98
+ const fetchMock = stubFetch();
99
+ const handler = createProxyHandler({ apiKey: 'pgk_live_x.y', apiServer: 'api.polyguard.ai' });
100
+ const req = makeRequest('PUT', 'http://localhost/api/polyguard/link/3', {
101
+ headers: { 'content-type': 'application/json' },
102
+ body: { type: 'login' },
103
+ });
104
+ const res = await handler(req);
105
+ expect(res.status).toBe(200);
106
+ expect(fetchMock.mock.calls[0][0]).toBe('https://api.polyguard.ai/link/3');
107
+ });
108
+
109
+ it('honors a custom allowedPaths list', async () => {
110
+ stubFetch();
111
+ const handler = createProxyHandler({
112
+ apiKey: 'pgk_live_x.y',
113
+ apiServer: 'api.polyguard.ai',
114
+ allowedPaths: ['POST /v2/ticket/'],
115
+ });
116
+ const denied = await handler(makeRequest('PUT', 'http://localhost/api/polyguard/link/3'));
117
+ expect(denied.status).toBe(404);
118
+ const allowed = await handler(makeRequest('POST', 'http://localhost/api/polyguard/v2/ticket/3', { body: {} }));
119
+ expect(allowed.status).toBe(200);
120
+ });
121
+
122
+ it('honors a custom pathPrefix', async () => {
123
+ const fetchMock = stubFetch();
124
+ const handler = createProxyHandler({
125
+ apiKey: 'pgk_live_x.y',
126
+ apiServer: 'api.polyguard.ai',
127
+ pathPrefix: '/custom/proxy',
128
+ });
129
+ await handler(makeRequest('POST', 'http://localhost/custom/proxy/v2/ticket/3', { body: {} }));
130
+ expect(fetchMock.mock.calls[0][0]).toBe('https://api.polyguard.ai/v2/ticket/3');
131
+ });
132
+
133
+ it('strips hop-by-hop response headers and set-cookie', async () => {
134
+ stubFetch({
135
+ headers: {
136
+ 'content-type': 'application/json',
137
+ 'set-cookie': 'session=abc; path=/',
138
+ 'transfer-encoding': 'chunked',
139
+ 'x-custom': 'visible',
140
+ },
141
+ });
142
+ const handler = createProxyHandler({ apiKey: 'pgk_live_x.y', apiServer: 'api.polyguard.ai' });
143
+ const res = await handler(makeRequest('POST', 'http://localhost/api/polyguard/v2/ticket/3', { body: {} }));
144
+ expect(res.headers.get('set-cookie')).toBeNull();
145
+ expect(res.headers.get('transfer-encoding')).toBeNull();
146
+ expect(res.headers.get('x-custom')).toBe('visible');
147
+ });
148
+ });
@@ -243,6 +243,30 @@ describe('sidebarUrl feature', () => {
243
243
  await promise;
244
244
  expect(window.location.assign).toHaveBeenCalledWith('https://teams.microsoft.com/meet/123');
245
245
  });
246
+
247
+ it('button survives a late WebSocket close event (race regression)', async () => {
248
+ // Real browsers dispatch the 'close' event asynchronously after ws.close(),
249
+ // so it lands after the synchronous button append. If the close listener's
250
+ // cleanup() runs in that window, it wipes the qrDiv — and the button with it.
251
+ // markClosed() in the message handler must defuse the close listener.
252
+ clearCookies();
253
+ vi.stubGlobal('open', vi.fn().mockReturnValue({}));
254
+ const client = new PolyguardWebsocketClientImpl({
255
+ ...DEFAULT_PARAMS,
256
+ sidebarUrl: 'https://teams.polyguard.ai/ms-teams/sidebar-standalone',
257
+ link_uuid: 'link-abc',
258
+ });
259
+ document.body.innerHTML = '<div id="test-target"></div>';
260
+ const jwtClaims = { redirect_url: 'https://teams.microsoft.com/meet/123' };
261
+ const { promise, ws } = await startVerifyAndGetWs(client, 'test-target');
262
+ ws.simulateMessage({ jwt: jwtClaims });
263
+ expect(document.querySelector('#polyguard-join-meeting')).not.toBeNull();
264
+ // Mimic a real browser dispatching close on the next tick (after the message handler returned).
265
+ ws.simulateClose();
266
+ expect(document.querySelector('#polyguard-join-meeting')).not.toBeNull();
267
+ document.querySelector('#polyguard-join-meeting').click();
268
+ await promise;
269
+ });
246
270
  });
247
271
 
248
272
  describe('with sidebarUrl but no redirect_url in JWT (non-meeting links)', () => {
@@ -61,6 +61,11 @@ export function handleWebSocketMessage(event, ctx) {
61
61
  // the SDK handles the redirect (and optionally opens a sidebar popup).
62
62
  if (sidebarUrl && redirectUrl) {
63
63
  const hasMsftSession = document.cookie.includes('pg_msft_session');
64
+ // Mark the socket as intentionally closed BEFORE ws.close() so the
65
+ // 'close' event listener in verify() skips its own cleanup(). Otherwise
66
+ // the async close handler races with the synchronous button append below
67
+ // and wipes the qrDiv after we've put the button in it.
68
+ ctx.markClosed();
64
69
  cleanup();
65
70
  ws.close();
66
71
 
package/src/server.js ADDED
@@ -0,0 +1,91 @@
1
+ const DEFAULT_ALLOWED_PATHS = [
2
+ 'POST /v2/ticket/',
3
+ 'PUT /link/',
4
+ 'GET /v2/preview/',
5
+ ];
6
+
7
+ const HOP_BY_HOP = new Set([
8
+ 'connection',
9
+ 'keep-alive',
10
+ 'proxy-authenticate',
11
+ 'proxy-authorization',
12
+ 'te',
13
+ 'trailer',
14
+ 'transfer-encoding',
15
+ 'upgrade',
16
+ ]);
17
+
18
+ function isAllowed(method, pathname, allowedPaths) {
19
+ const upper = method.toUpperCase();
20
+ for (const rule of allowedPaths) {
21
+ const idx = rule.indexOf(' ');
22
+ if (idx < 0) continue;
23
+ const ruleMethod = rule.slice(0, idx).toUpperCase();
24
+ const rulePrefix = rule.slice(idx + 1);
25
+ if (ruleMethod === upper && pathname.startsWith(rulePrefix)) {
26
+ return true;
27
+ }
28
+ }
29
+ return false;
30
+ }
31
+
32
+ export function createProxyHandler(options = {}) {
33
+ const { apiKey, apiServer = 'api.polyguard.ai', allowedPaths = DEFAULT_ALLOWED_PATHS, pathPrefix = '/api/polyguard' } = options;
34
+
35
+ if (!apiKey) {
36
+ throw new Error('createProxyHandler: apiKey is required');
37
+ }
38
+ if (!apiServer) {
39
+ throw new Error('createProxyHandler: apiServer is required');
40
+ }
41
+
42
+ return async function polyguardProxy(request) {
43
+ const url = new URL(request.url);
44
+ let pathname = url.pathname;
45
+ if (pathPrefix && pathname.startsWith(pathPrefix)) {
46
+ pathname = pathname.slice(pathPrefix.length) || '/';
47
+ }
48
+
49
+ if (!isAllowed(request.method, pathname, allowedPaths)) {
50
+ return new Response('Not Found', { status: 404 });
51
+ }
52
+
53
+ const targetUrl = `https://${apiServer}${pathname}${url.search}`;
54
+
55
+ const forwardHeaders = new Headers();
56
+ for (const [name, value] of request.headers) {
57
+ const lower = name.toLowerCase();
58
+ if (lower === 'x-pg-api-key' || lower === 'authorization') continue;
59
+ if (HOP_BY_HOP.has(lower)) continue;
60
+ if (lower === 'host') continue;
61
+ forwardHeaders.set(name, value);
62
+ }
63
+ forwardHeaders.set('x-pg-api-key', apiKey);
64
+
65
+ const init = {
66
+ method: request.method,
67
+ headers: forwardHeaders,
68
+ redirect: 'manual',
69
+ };
70
+ if (request.method !== 'GET' && request.method !== 'HEAD') {
71
+ init.body = request.body;
72
+ init.duplex = 'half';
73
+ }
74
+
75
+ const upstream = await fetch(targetUrl, init);
76
+
77
+ const responseHeaders = new Headers();
78
+ for (const [name, value] of upstream.headers) {
79
+ const lower = name.toLowerCase();
80
+ if (HOP_BY_HOP.has(lower)) continue;
81
+ if (lower === 'set-cookie') continue;
82
+ responseHeaders.set(name, value);
83
+ }
84
+
85
+ return new Response(upstream.body, {
86
+ status: upstream.status,
87
+ statusText: upstream.statusText,
88
+ headers: responseHeaders,
89
+ });
90
+ };
91
+ }
@@ -1,7 +1,11 @@
1
- export async function fetchTicket({ apiServer, appId, link_uuid, requiredProofs, scanType }) {
1
+ export async function fetchTicket({ apiServer, proxyUrl, appId, link_uuid, requiredProofs, scanType }) {
2
+ const base = proxyUrl
3
+ ? proxyUrl.replace(/\/$/, '')
4
+ : `https://${apiServer}`;
5
+
2
6
  const ticketUrl = link_uuid
3
- ? `https://${apiServer}/v2/ticket/${appId}/${link_uuid}`
4
- : `https://${apiServer}/v2/ticket/${appId}`;
7
+ ? `${base}/v2/ticket/${appId}/${link_uuid}`
8
+ : `${base}/v2/ticket/${appId}`;
5
9
 
6
10
  const ticketRes = await fetch(ticketUrl, {
7
11
  method: 'POST',