@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.
@@ -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.2",
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,
@@ -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
+ });
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',