@wp-playground/mcp 3.1.5
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/.eslintrc.json +24 -0
- package/README.md +96 -0
- package/e2e/mcp-tools.spec.ts +679 -0
- package/package.json +46 -0
- package/playwright.config.ts +35 -0
- package/project.json +64 -0
- package/src/bridge-client.ts +196 -0
- package/src/bridge-server.spec.ts +228 -0
- package/src/bridge-server.ts +485 -0
- package/src/client.ts +3 -0
- package/src/index.ts +28 -0
- package/src/mcp-server.ts +34 -0
- package/src/tools/register-mcp-server-tools.ts +347 -0
- package/src/tools/tool-definitions.ts +527 -0
- package/src/tools/tool-executors.ts +205 -0
- package/tsconfig.json +15 -0
- package/tsconfig.lib.json +10 -0
- package/vite.config.ts +49 -0
package/package.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@wp-playground/mcp",
|
|
3
|
+
"version": "3.1.5",
|
|
4
|
+
"description": "MCP server for WordPress Playground - enables AI agents to interact with the WordPress Playground website.",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "https://github.com/WordPress/wordpress-playground"
|
|
8
|
+
},
|
|
9
|
+
"homepage": "https://developer.wordpress.org/playground",
|
|
10
|
+
"author": "The WordPress contributors",
|
|
11
|
+
"license": "GPL-2.0-or-later",
|
|
12
|
+
"type": "module",
|
|
13
|
+
"bin": {
|
|
14
|
+
"wp-playground-mcp": "./index.js"
|
|
15
|
+
},
|
|
16
|
+
"exports": {
|
|
17
|
+
".": {
|
|
18
|
+
"import": "./src/index.ts",
|
|
19
|
+
"require": "./index.cjs"
|
|
20
|
+
},
|
|
21
|
+
"./client": {
|
|
22
|
+
"import": "./src/client.ts",
|
|
23
|
+
"require": "./client.cjs"
|
|
24
|
+
},
|
|
25
|
+
"./package.json": "./package.json"
|
|
26
|
+
},
|
|
27
|
+
"publishConfig": {
|
|
28
|
+
"access": "public",
|
|
29
|
+
"directory": "../../../dist/packages/playground/mcp"
|
|
30
|
+
},
|
|
31
|
+
"main": "./index.cjs",
|
|
32
|
+
"module": "./index.js",
|
|
33
|
+
"types": "index.d.ts",
|
|
34
|
+
"engines": {
|
|
35
|
+
"node": ">=20.10.0",
|
|
36
|
+
"npm": ">=10.2.3"
|
|
37
|
+
},
|
|
38
|
+
"dependencies": {
|
|
39
|
+
"@modelcontextprotocol/sdk": "^1.27.0",
|
|
40
|
+
"ws": "^8.18.0",
|
|
41
|
+
"zod": "^4.3"
|
|
42
|
+
},
|
|
43
|
+
"devDependencies": {
|
|
44
|
+
"@types/ws": "^8.18.0"
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { defineConfig, devices } from '@playwright/test';
|
|
2
|
+
|
|
3
|
+
export default defineConfig({
|
|
4
|
+
testDir: './e2e',
|
|
5
|
+
fullyParallel: false,
|
|
6
|
+
forbidOnly: !!process.env.CI,
|
|
7
|
+
retries: process.env.CI ? 2 : 0,
|
|
8
|
+
// Must be 1: the MCP server supports only one browser connection at a time.
|
|
9
|
+
workers: 1,
|
|
10
|
+
reporter: [['list', { printSteps: true }]],
|
|
11
|
+
use: {
|
|
12
|
+
baseURL: 'http://127.0.0.1:5400/website-server/',
|
|
13
|
+
trace: 'on-first-retry',
|
|
14
|
+
actionTimeout: 120_000,
|
|
15
|
+
navigationTimeout: 120_000,
|
|
16
|
+
},
|
|
17
|
+
timeout: 300_000,
|
|
18
|
+
expect: { timeout: 60_000 },
|
|
19
|
+
projects: [
|
|
20
|
+
{
|
|
21
|
+
name: 'chromium',
|
|
22
|
+
use: {
|
|
23
|
+
...devices['Desktop Chrome'],
|
|
24
|
+
launchOptions: {
|
|
25
|
+
args: ['--js-flags=--enable-experimental-webassembly-jspi'],
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
],
|
|
30
|
+
webServer: {
|
|
31
|
+
command: 'npx nx run playground-website:dev',
|
|
32
|
+
url: 'http://127.0.0.1:5400/website-server/',
|
|
33
|
+
reuseExistingServer: !process.env.CI,
|
|
34
|
+
},
|
|
35
|
+
});
|
package/project.json
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "playground-mcp",
|
|
3
|
+
"$schema": "../../../node_modules/nx/schemas/project-schema.json",
|
|
4
|
+
"sourceRoot": "packages/playground/mcp/src",
|
|
5
|
+
"projectType": "library",
|
|
6
|
+
"tags": [],
|
|
7
|
+
"targets": {
|
|
8
|
+
"build": {
|
|
9
|
+
"executor": "nx:noop",
|
|
10
|
+
"dependsOn": ["build:package-json"]
|
|
11
|
+
},
|
|
12
|
+
"build:package-json": {
|
|
13
|
+
"executor": "@wp-playground/nx-extensions:package-json",
|
|
14
|
+
"options": {
|
|
15
|
+
"tsConfig": "packages/playground/mcp/tsconfig.lib.json",
|
|
16
|
+
"outputPath": "dist/packages/playground/mcp",
|
|
17
|
+
"buildTarget": "playground-mcp:build:bundle:production"
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
"build:bundle": {
|
|
21
|
+
"executor": "@nx/vite:build",
|
|
22
|
+
"outputs": ["{options.outputPath}"],
|
|
23
|
+
"options": {
|
|
24
|
+
"emptyOutDir": false,
|
|
25
|
+
"outputPath": "dist/packages/playground/mcp"
|
|
26
|
+
},
|
|
27
|
+
"defaultConfiguration": "production",
|
|
28
|
+
"configurations": {
|
|
29
|
+
"development": {
|
|
30
|
+
"minify": false
|
|
31
|
+
},
|
|
32
|
+
"production": {
|
|
33
|
+
"minify": true
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
"test:mcp": {
|
|
38
|
+
"executor": "nx:run-commands",
|
|
39
|
+
"options": {
|
|
40
|
+
"commands": [
|
|
41
|
+
{
|
|
42
|
+
"command": "node -e \"if (parseInt(process.versions.node) < 22) { console.error('Node.js version 22 or greater is required'); process.exit(1); }\"",
|
|
43
|
+
"forwardAllArgs": false
|
|
44
|
+
},
|
|
45
|
+
"npx playwright test --config=packages/playground/mcp/playwright.config.ts"
|
|
46
|
+
],
|
|
47
|
+
"parallel": false
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
"package-for-self-hosting": {
|
|
51
|
+
"executor": "@wp-playground/nx-extensions:package-for-self-hosting",
|
|
52
|
+
"dependsOn": ["build"]
|
|
53
|
+
},
|
|
54
|
+
"lint": {
|
|
55
|
+
"executor": "@nx/eslint:lint",
|
|
56
|
+
"outputs": ["{options.outputFile}"],
|
|
57
|
+
"options": {
|
|
58
|
+
"useFlatConfig": false,
|
|
59
|
+
"lintFilePatterns": ["packages/playground/mcp/**/*.ts"],
|
|
60
|
+
"maxWarnings": 0
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import type { PlaygroundClient } from '@wp-playground/remote';
|
|
2
|
+
import { createToolClient } from './tools/tool-executors';
|
|
3
|
+
import type { ToolClient } from './tools/tool-executors';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Shared configuration for the MCP bridge client and WebMCP.
|
|
7
|
+
*
|
|
8
|
+
* Both transports need the same callbacks to interact with
|
|
9
|
+
* the Playground site list and active client.
|
|
10
|
+
*/
|
|
11
|
+
export interface PlaygroundConfig {
|
|
12
|
+
getSites: () => Array<{
|
|
13
|
+
slug: string;
|
|
14
|
+
name: string;
|
|
15
|
+
storage: string;
|
|
16
|
+
isActive: boolean;
|
|
17
|
+
}>;
|
|
18
|
+
getPlaygroundClient: (siteSlug: string) => PlaygroundClient | undefined;
|
|
19
|
+
renameSite?: (siteSlug: string, newName: string) => Promise<void>;
|
|
20
|
+
saveSite?: (siteSlug: string) => Promise<{ slug: string; storage: string }>;
|
|
21
|
+
onConnect?: () => void;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface McpBridgeHandle {
|
|
25
|
+
notifySitesChanged: () => void;
|
|
26
|
+
stop: () => void;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const RECONNECT_INTERVAL_MS = 5000;
|
|
30
|
+
|
|
31
|
+
export function startMcpBridge(
|
|
32
|
+
config: PlaygroundConfig,
|
|
33
|
+
port: number
|
|
34
|
+
): McpBridgeHandle {
|
|
35
|
+
const tabId = crypto.randomUUID();
|
|
36
|
+
let ws: WebSocket | null = null;
|
|
37
|
+
let previousSitesSerialized = '';
|
|
38
|
+
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
|
39
|
+
let stopped = false;
|
|
40
|
+
|
|
41
|
+
function sendSitesRegistration(socket: WebSocket) {
|
|
42
|
+
const sites = config.getSites();
|
|
43
|
+
const serialized = JSON.stringify(sites);
|
|
44
|
+
if (serialized === previousSitesSerialized) {
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
previousSitesSerialized = serialized;
|
|
48
|
+
socket.send(JSON.stringify({ type: 'register', tabId, sites }));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function connect() {
|
|
52
|
+
try {
|
|
53
|
+
const response = await fetch(
|
|
54
|
+
`http://127.0.0.1:${port}/bridge-token`
|
|
55
|
+
);
|
|
56
|
+
if (!response.ok) {
|
|
57
|
+
scheduleReconnect();
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
const { token } = await response.json();
|
|
61
|
+
ws = new WebSocket(`ws://127.0.0.1:${port}?token=${token}`);
|
|
62
|
+
} catch {
|
|
63
|
+
scheduleReconnect();
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
ws.addEventListener('open', () => {
|
|
68
|
+
previousSitesSerialized = '';
|
|
69
|
+
sendSitesRegistration(ws!);
|
|
70
|
+
config.onConnect?.();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
ws.addEventListener('message', async (event) => {
|
|
74
|
+
let message;
|
|
75
|
+
try {
|
|
76
|
+
message = JSON.parse(event.data as string);
|
|
77
|
+
} catch {
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
if (message.type !== 'command') {
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const { id, method, args, siteSlug } = message;
|
|
85
|
+
try {
|
|
86
|
+
const value = await handleCommand(
|
|
87
|
+
config,
|
|
88
|
+
method,
|
|
89
|
+
args || [],
|
|
90
|
+
siteSlug
|
|
91
|
+
);
|
|
92
|
+
if (ws?.readyState === WebSocket.OPEN) {
|
|
93
|
+
ws.send(JSON.stringify({ id, type: 'response', value }));
|
|
94
|
+
}
|
|
95
|
+
} catch (error) {
|
|
96
|
+
const errorMsg =
|
|
97
|
+
error instanceof Error ? error.message : String(error);
|
|
98
|
+
if (ws?.readyState === WebSocket.OPEN) {
|
|
99
|
+
ws.send(
|
|
100
|
+
JSON.stringify({
|
|
101
|
+
id,
|
|
102
|
+
type: 'response',
|
|
103
|
+
error: errorMsg,
|
|
104
|
+
})
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
ws.addEventListener('close', () => {
|
|
111
|
+
ws = null;
|
|
112
|
+
scheduleReconnect();
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
ws.addEventListener('error', () => {
|
|
116
|
+
// Error will be followed by close event,
|
|
117
|
+
// which handles reconnect
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function scheduleReconnect() {
|
|
122
|
+
if (stopped) {
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
reconnectTimer = setTimeout(connect, RECONNECT_INTERVAL_MS);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
connect();
|
|
129
|
+
|
|
130
|
+
return {
|
|
131
|
+
notifySitesChanged: () => {
|
|
132
|
+
if (ws?.readyState === WebSocket.OPEN) {
|
|
133
|
+
sendSitesRegistration(ws);
|
|
134
|
+
}
|
|
135
|
+
},
|
|
136
|
+
stop: () => {
|
|
137
|
+
stopped = true;
|
|
138
|
+
if (reconnectTimer !== null) {
|
|
139
|
+
clearTimeout(reconnectTimer);
|
|
140
|
+
reconnectTimer = null;
|
|
141
|
+
}
|
|
142
|
+
if (ws) {
|
|
143
|
+
ws.close();
|
|
144
|
+
ws = null;
|
|
145
|
+
}
|
|
146
|
+
},
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async function handleCommand(
|
|
151
|
+
config: PlaygroundConfig,
|
|
152
|
+
method: string,
|
|
153
|
+
args: unknown[],
|
|
154
|
+
siteSlug: string
|
|
155
|
+
): Promise<unknown> {
|
|
156
|
+
if (method === '__open_site') {
|
|
157
|
+
const url = new URL(window.location.href);
|
|
158
|
+
url.searchParams.set('site-slug', siteSlug);
|
|
159
|
+
const newWindow = window.open(url.toString(), '_blank');
|
|
160
|
+
if (!newWindow) {
|
|
161
|
+
throw new Error(
|
|
162
|
+
'Pop-up blocked by browser. The user ' +
|
|
163
|
+
'must allow pop-ups for this site.'
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
return true;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (method === '__rename_site') {
|
|
170
|
+
if (!config.renameSite) {
|
|
171
|
+
throw new Error('renameSite not configured');
|
|
172
|
+
}
|
|
173
|
+
const [newName] = args as [string];
|
|
174
|
+
await config.renameSite(siteSlug, newName);
|
|
175
|
+
return true;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (method === '__save_site') {
|
|
179
|
+
if (!config.saveSite) {
|
|
180
|
+
throw new Error('saveSite not configured');
|
|
181
|
+
}
|
|
182
|
+
return await config.saveSite(siteSlug);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const playgroundClient = config.getPlaygroundClient(siteSlug);
|
|
186
|
+
if (!playgroundClient) {
|
|
187
|
+
throw new Error(`No active client for site: ${siteSlug}`);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const client = createToolClient(playgroundClient);
|
|
191
|
+
const fn = client[method as keyof ToolClient];
|
|
192
|
+
if (typeof fn !== 'function') {
|
|
193
|
+
throw new Error(`Unknown method: ${method}`);
|
|
194
|
+
}
|
|
195
|
+
return await (fn as (...a: unknown[]) => Promise<unknown>)(...args);
|
|
196
|
+
}
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import { describe, it, expect, afterEach } from 'vitest';
|
|
2
|
+
import WebSocket from 'ws';
|
|
3
|
+
import { PlaygroundBridge } from './bridge-server';
|
|
4
|
+
|
|
5
|
+
// Use high random port to avoid conflicts
|
|
6
|
+
const getPort = () => 19000 + Math.floor(Math.random() * 1000);
|
|
7
|
+
|
|
8
|
+
describe('PlaygroundBridge token endpoint', () => {
|
|
9
|
+
let bridge: PlaygroundBridge;
|
|
10
|
+
|
|
11
|
+
afterEach(async () => {
|
|
12
|
+
await bridge?.close();
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('returns a session token for allowed origins', async () => {
|
|
16
|
+
const port = getPort();
|
|
17
|
+
bridge = new PlaygroundBridge();
|
|
18
|
+
await bridge.startWebSocketServer(port);
|
|
19
|
+
|
|
20
|
+
const response = await fetch(`http://127.0.0.1:${port}/bridge-token`, {
|
|
21
|
+
headers: { Origin: 'http://localhost:5400' },
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
expect(response.status).toBe(200);
|
|
25
|
+
const body = await response.json();
|
|
26
|
+
expect(body.token).toMatch(
|
|
27
|
+
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
|
|
28
|
+
);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('returns the same token on repeated requests', async () => {
|
|
32
|
+
const port = getPort();
|
|
33
|
+
bridge = new PlaygroundBridge();
|
|
34
|
+
await bridge.startWebSocketServer(port);
|
|
35
|
+
|
|
36
|
+
const headers = { Origin: 'http://localhost:5400' };
|
|
37
|
+
const r1 = await fetch(`http://127.0.0.1:${port}/bridge-token`, {
|
|
38
|
+
headers,
|
|
39
|
+
});
|
|
40
|
+
const r2 = await fetch(`http://127.0.0.1:${port}/bridge-token`, {
|
|
41
|
+
headers,
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const t1 = (await r1.json()).token;
|
|
45
|
+
const t2 = (await r2.json()).token;
|
|
46
|
+
expect(t1).toBe(t2);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('rejects token requests from disallowed origins', async () => {
|
|
50
|
+
const port = getPort();
|
|
51
|
+
bridge = new PlaygroundBridge();
|
|
52
|
+
await bridge.startWebSocketServer(port);
|
|
53
|
+
|
|
54
|
+
const response = await fetch(`http://127.0.0.1:${port}/bridge-token`, {
|
|
55
|
+
headers: { Origin: 'https://evil.com' },
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
expect(response.status).toBe(403);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('returns CORS headers matching the request origin', async () => {
|
|
62
|
+
const port = getPort();
|
|
63
|
+
bridge = new PlaygroundBridge();
|
|
64
|
+
await bridge.startWebSocketServer(port);
|
|
65
|
+
|
|
66
|
+
const response = await fetch(`http://127.0.0.1:${port}/bridge-token`, {
|
|
67
|
+
headers: { Origin: 'http://127.0.0.1:5400' },
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
expect(response.headers.get('access-control-allow-origin')).toBe(
|
|
71
|
+
'http://127.0.0.1:5400'
|
|
72
|
+
);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('handles CORS preflight OPTIONS request for disallowed origins', async () => {
|
|
76
|
+
const port = getPort();
|
|
77
|
+
bridge = new PlaygroundBridge();
|
|
78
|
+
await bridge.startWebSocketServer(port);
|
|
79
|
+
|
|
80
|
+
const response = await fetch(`http://127.0.0.1:${port}/bridge-token`, {
|
|
81
|
+
method: 'OPTIONS',
|
|
82
|
+
headers: {
|
|
83
|
+
Origin: 'https://evil.com',
|
|
84
|
+
'Access-Control-Request-Method': 'GET',
|
|
85
|
+
},
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
expect(response.status).toBe(403);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('handles CORS preflight OPTIONS request', async () => {
|
|
92
|
+
const port = getPort();
|
|
93
|
+
bridge = new PlaygroundBridge();
|
|
94
|
+
await bridge.startWebSocketServer(port);
|
|
95
|
+
|
|
96
|
+
const response = await fetch(`http://127.0.0.1:${port}/bridge-token`, {
|
|
97
|
+
method: 'OPTIONS',
|
|
98
|
+
headers: {
|
|
99
|
+
Origin: 'http://localhost:5400',
|
|
100
|
+
'Access-Control-Request-Method': 'GET',
|
|
101
|
+
},
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
expect(response.status).toBe(204);
|
|
105
|
+
expect(response.headers.get('access-control-allow-origin')).toBe(
|
|
106
|
+
'http://localhost:5400'
|
|
107
|
+
);
|
|
108
|
+
expect(response.headers.get('access-control-allow-methods')).toBe(
|
|
109
|
+
'GET'
|
|
110
|
+
);
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
function waitForWebSocket(
|
|
115
|
+
ws: WebSocket,
|
|
116
|
+
state: typeof WebSocket.OPEN | typeof WebSocket.CLOSED
|
|
117
|
+
): Promise<void> {
|
|
118
|
+
return new Promise((resolve, reject) => {
|
|
119
|
+
const timeout = setTimeout(() => reject(new Error('Timeout')), 5000);
|
|
120
|
+
if (ws.readyState === state) {
|
|
121
|
+
clearTimeout(timeout);
|
|
122
|
+
resolve();
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
ws.on('open', () => {
|
|
126
|
+
if (state === WebSocket.OPEN) {
|
|
127
|
+
clearTimeout(timeout);
|
|
128
|
+
resolve();
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
ws.on('close', () => {
|
|
132
|
+
clearTimeout(timeout);
|
|
133
|
+
if (state === WebSocket.CLOSED) {
|
|
134
|
+
resolve();
|
|
135
|
+
} else {
|
|
136
|
+
reject(new Error('WebSocket closed unexpectedly'));
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
ws.on('error', () => {
|
|
140
|
+
// close event follows
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
describe('PlaygroundBridge token validation', () => {
|
|
146
|
+
let bridge: PlaygroundBridge;
|
|
147
|
+
|
|
148
|
+
afterEach(async () => {
|
|
149
|
+
await bridge?.close();
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('accepts WebSocket connections with a valid token', async () => {
|
|
153
|
+
const port = getPort();
|
|
154
|
+
bridge = new PlaygroundBridge();
|
|
155
|
+
await bridge.startWebSocketServer(port);
|
|
156
|
+
|
|
157
|
+
const res = await fetch(`http://127.0.0.1:${port}/bridge-token`, {
|
|
158
|
+
headers: { Origin: 'http://localhost:5400' },
|
|
159
|
+
});
|
|
160
|
+
const { token } = await res.json();
|
|
161
|
+
|
|
162
|
+
const ws = new WebSocket(`ws://127.0.0.1:${port}?token=${token}`);
|
|
163
|
+
await waitForWebSocket(ws, WebSocket.OPEN);
|
|
164
|
+
expect(ws.readyState).toBe(WebSocket.OPEN);
|
|
165
|
+
ws.close();
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('rejects WebSocket connections without a token', async () => {
|
|
169
|
+
const port = getPort();
|
|
170
|
+
bridge = new PlaygroundBridge();
|
|
171
|
+
await bridge.startWebSocketServer(port);
|
|
172
|
+
|
|
173
|
+
const ws = new WebSocket(`ws://127.0.0.1:${port}`);
|
|
174
|
+
await waitForWebSocket(ws, WebSocket.CLOSED);
|
|
175
|
+
expect(ws.readyState).toBe(WebSocket.CLOSED);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('rejects WebSocket connections with an invalid token', async () => {
|
|
179
|
+
const port = getPort();
|
|
180
|
+
bridge = new PlaygroundBridge();
|
|
181
|
+
await bridge.startWebSocketServer(port);
|
|
182
|
+
|
|
183
|
+
const ws = new WebSocket(`ws://127.0.0.1:${port}?token=wrong-token`);
|
|
184
|
+
await waitForWebSocket(ws, WebSocket.CLOSED);
|
|
185
|
+
expect(ws.readyState).toBe(WebSocket.CLOSED);
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
describe('Origin allowlist', () => {
|
|
190
|
+
let bridge: PlaygroundBridge;
|
|
191
|
+
|
|
192
|
+
afterEach(async () => {
|
|
193
|
+
await bridge?.close();
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it('allows playground.wordpress.net', async () => {
|
|
197
|
+
const port = getPort();
|
|
198
|
+
bridge = new PlaygroundBridge();
|
|
199
|
+
await bridge.startWebSocketServer(port);
|
|
200
|
+
|
|
201
|
+
const response = await fetch(`http://127.0.0.1:${port}/bridge-token`, {
|
|
202
|
+
headers: {
|
|
203
|
+
Origin: 'https://playground.wordpress.net',
|
|
204
|
+
},
|
|
205
|
+
});
|
|
206
|
+
expect(response.status).toBe(200);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it('rejects non-localhost origins', async () => {
|
|
210
|
+
const port = getPort();
|
|
211
|
+
bridge = new PlaygroundBridge();
|
|
212
|
+
await bridge.startWebSocketServer(port);
|
|
213
|
+
|
|
214
|
+
const response = await fetch(`http://127.0.0.1:${port}/bridge-token`, {
|
|
215
|
+
headers: { Origin: 'https://example.com' },
|
|
216
|
+
});
|
|
217
|
+
expect(response.status).toBe(403);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it('rejects requests without an Origin header', async () => {
|
|
221
|
+
const port = getPort();
|
|
222
|
+
bridge = new PlaygroundBridge();
|
|
223
|
+
await bridge.startWebSocketServer(port);
|
|
224
|
+
|
|
225
|
+
const response = await fetch(`http://127.0.0.1:${port}/bridge-token`);
|
|
226
|
+
expect(response.status).toBe(403);
|
|
227
|
+
});
|
|
228
|
+
});
|