@polyguard/sdk 1.4.2 → 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/coverage/base.css +224 -0
- package/coverage/block-navigation.js +87 -0
- package/coverage/clover.xml +268 -0
- package/coverage/coverage-final.json +8 -0
- package/coverage/favicon.png +0 -0
- package/coverage/index.html +131 -0
- package/coverage/prettify.css +1 -0
- package/coverage/prettify.js +2 -0
- package/coverage/sort-arrow-sprite.png +0 -0
- package/coverage/sorter.js +210 -0
- package/coverage/src/PolyguardClient.js.html +160 -0
- package/coverage/src/PolyguardWebsocketClientImpl.js.html +634 -0
- package/coverage/src/__tests__/helpers/fixtures.js.html +166 -0
- package/coverage/src/__tests__/helpers/index.html +131 -0
- package/coverage/src/__tests__/helpers/mockWebSocket.js.html +283 -0
- package/coverage/src/index.html +176 -0
- package/coverage/src/messageHandler.js.html +448 -0
- package/coverage/src/ticketService.js.html +157 -0
- package/coverage/src/ui.js.html +349 -0
- package/dist/sdk.esm.js +16 -4
- package/dist/sdk.js +16 -4
- package/dist/server.esm.js +83 -0
- package/package.json +9 -2
- package/src/PolyguardWebsocketClientImpl.js +17 -2
- package/src/__tests__/PolyguardWebsocketClientImpl.test.js +45 -2
- package/src/__tests__/server.test.js +148 -0
- package/src/server.js +91 -0
- package/src/ticketService.js +7 -3
|
@@ -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.
|
|
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
|
-
|
|
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,
|
|
@@ -162,11 +162,35 @@ describe('PolyguardWebsocketClientImpl', () => {
|
|
|
162
162
|
expect(client.apiClient.basePath).toBe('https://custom.api');
|
|
163
163
|
});
|
|
164
164
|
|
|
165
|
-
it('
|
|
166
|
-
|
|
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
|
+
});
|
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
|
+
}
|
package/src/ticketService.js
CHANGED
|
@@ -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
|
-
?
|
|
4
|
-
:
|
|
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',
|