@plingjs/surface-gateway 0.1.0-alpha.6 → 0.1.0-alpha.7
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/bin/surface-gateway.mjs +0 -0
- package/package.json +12 -17
- package/version.mjs +1 -0
- package/dist/README.md +0 -178
- package/dist/bin/surface-gateway.mjs +0 -19
- package/dist/config.example.json +0 -33
- package/dist/package.json +0 -37
- package/dist/version.mjs +0 -1
- package/src/cli.mjs +0 -132
- package/src/config.mjs +0 -214
- package/src/errors.mjs +0 -15
- package/src/gateway.mjs +0 -935
- package/src/index.d.ts +0 -273
- package/src/index.mjs +0 -7
- package/src/mcp.mjs +0 -145
- package/src/server.mjs +0 -462
- package/src/version.mjs +0 -1
- package/test/cli.test.mjs +0 -29
- package/test/config.test.mjs +0 -56
- package/test/gateway.test.mjs +0 -372
- package/test/mcp.test.mjs +0 -88
- package/test/transport.test.mjs +0 -363
- /package/{dist/LICENSE → LICENSE} +0 -0
- /package/{dist/NOTICE → NOTICE} +0 -0
- /package/{dist/cli.mjs → cli.mjs} +0 -0
- /package/{dist/config.mjs → config.mjs} +0 -0
- /package/{dist/errors.mjs → errors.mjs} +0 -0
- /package/{dist/gateway.mjs → gateway.mjs} +0 -0
- /package/{dist/index.d.ts → index.d.ts} +0 -0
- /package/{dist/index.mjs → index.mjs} +0 -0
- /package/{dist/mcp.mjs → mcp.mjs} +0 -0
- /package/{dist/server.mjs → server.mjs} +0 -0
package/test/transport.test.mjs
DELETED
|
@@ -1,363 +0,0 @@
|
|
|
1
|
-
import assert from "node:assert/strict";
|
|
2
|
-
import { once } from "node:events";
|
|
3
|
-
import test from "node:test";
|
|
4
|
-
import { WebSocket } from "ws";
|
|
5
|
-
import { normalizeConfig } from "../src/config.mjs";
|
|
6
|
-
import { createSurfaceServer } from "../src/server.mjs";
|
|
7
|
-
|
|
8
|
-
const ORIGIN = "https://demo.example";
|
|
9
|
-
const PUBLIC_DEMO_ORIGIN = "https://plingjs.dev";
|
|
10
|
-
const ADMIN = "transport-admin-token";
|
|
11
|
-
|
|
12
|
-
async function startServer(options = {}) {
|
|
13
|
-
const server = createSurfaceServer({
|
|
14
|
-
config: normalizeConfig({
|
|
15
|
-
tenants: [
|
|
16
|
-
{
|
|
17
|
-
id: "demo",
|
|
18
|
-
displayName: "Demo",
|
|
19
|
-
origins: [ORIGIN, PUBLIC_DEMO_ORIGIN],
|
|
20
|
-
publicDemo: {
|
|
21
|
-
origins: [PUBLIC_DEMO_ORIGIN],
|
|
22
|
-
scope: ["todo.overview", "todo.add"],
|
|
23
|
-
maxSessionsPerWindow: 2,
|
|
24
|
-
windowMs: 60_000,
|
|
25
|
-
maxActiveSessions: 10,
|
|
26
|
-
},
|
|
27
|
-
},
|
|
28
|
-
],
|
|
29
|
-
}),
|
|
30
|
-
adminToken: ADMIN,
|
|
31
|
-
port: 0,
|
|
32
|
-
host: "127.0.0.1",
|
|
33
|
-
logger: () => {},
|
|
34
|
-
trustProxy: options.trustProxy ?? false,
|
|
35
|
-
gateway: { requestTimeoutMs: 500, approvalTimeoutMs: 2_000 },
|
|
36
|
-
});
|
|
37
|
-
return { server, ...(await server.start()) };
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
test("public demo endpoint issues a scoped page session without an admin token", async (t) => {
|
|
41
|
-
const { server, baseUrl } = await startServer();
|
|
42
|
-
t.after(async () => {
|
|
43
|
-
await server.stop();
|
|
44
|
-
});
|
|
45
|
-
|
|
46
|
-
const denied = await call(`${baseUrl}/v1/demo/page-sessions`, {
|
|
47
|
-
method: "POST",
|
|
48
|
-
headers: jsonHeaders(null, ORIGIN),
|
|
49
|
-
body: "{}",
|
|
50
|
-
});
|
|
51
|
-
assert.equal(denied.response.status, 403);
|
|
52
|
-
assert.equal(denied.body.error.code, "ORIGIN_NOT_ALLOWED");
|
|
53
|
-
|
|
54
|
-
const created = await call(`${baseUrl}/v1/demo/page-sessions`, {
|
|
55
|
-
method: "POST",
|
|
56
|
-
headers: jsonHeaders(null, PUBLIC_DEMO_ORIGIN),
|
|
57
|
-
body: "{}",
|
|
58
|
-
});
|
|
59
|
-
assert.equal(created.response.status, 201);
|
|
60
|
-
assert.equal(created.body.tenantId, "demo");
|
|
61
|
-
assert.match(created.body.surfaceUrl, /^ws:\/\//);
|
|
62
|
-
|
|
63
|
-
const ws = new WebSocket(`${created.body.surfaceUrl}?token=${created.body.token}`, { origin: PUBLIC_DEMO_ORIGIN });
|
|
64
|
-
const inbox = createInbox(ws);
|
|
65
|
-
await once(ws, "open");
|
|
66
|
-
ws.send(JSON.stringify({ type: "hello", protocol: "pling.bridge/0.1", app: "public-demo-test" }));
|
|
67
|
-
assert.equal((await inbox.waitFor((message) => message.type === "hello_ack")).protocol, "pling.bridge/0.1");
|
|
68
|
-
ws.close();
|
|
69
|
-
});
|
|
70
|
-
|
|
71
|
-
test("untrusted X-Forwarded-For headers cannot bypass public demo rate limits", async (t) => {
|
|
72
|
-
const { server, baseUrl } = await startServer({ trustProxy: false });
|
|
73
|
-
t.after(async () => {
|
|
74
|
-
await server.stop();
|
|
75
|
-
});
|
|
76
|
-
|
|
77
|
-
for (const address of ["203.0.113.1", "203.0.113.2"]) {
|
|
78
|
-
const created = await call(`${baseUrl}/v1/demo/page-sessions`, {
|
|
79
|
-
method: "POST",
|
|
80
|
-
headers: { ...jsonHeaders(null, PUBLIC_DEMO_ORIGIN), "x-forwarded-for": address },
|
|
81
|
-
body: "{}",
|
|
82
|
-
});
|
|
83
|
-
assert.equal(created.response.status, 201);
|
|
84
|
-
}
|
|
85
|
-
const limited = await call(`${baseUrl}/v1/demo/page-sessions`, {
|
|
86
|
-
method: "POST",
|
|
87
|
-
headers: { ...jsonHeaders(null, PUBLIC_DEMO_ORIGIN), "x-forwarded-for": "203.0.113.3" },
|
|
88
|
-
body: "{}",
|
|
89
|
-
});
|
|
90
|
-
assert.equal(limited.response.status, 429);
|
|
91
|
-
assert.equal(limited.body.error.code, "RATE_LIMITED");
|
|
92
|
-
});
|
|
93
|
-
|
|
94
|
-
test("trustProxy uses X-Forwarded-For only when explicitly enabled", async (t) => {
|
|
95
|
-
const { server, baseUrl } = await startServer({ trustProxy: true });
|
|
96
|
-
t.after(async () => {
|
|
97
|
-
await server.stop();
|
|
98
|
-
});
|
|
99
|
-
|
|
100
|
-
for (const address of ["203.0.113.1", "203.0.113.1"]) {
|
|
101
|
-
const created = await call(`${baseUrl}/v1/demo/page-sessions`, {
|
|
102
|
-
method: "POST",
|
|
103
|
-
headers: { ...jsonHeaders(null, PUBLIC_DEMO_ORIGIN), "x-forwarded-for": address },
|
|
104
|
-
body: "{}",
|
|
105
|
-
});
|
|
106
|
-
assert.equal(created.response.status, 201);
|
|
107
|
-
}
|
|
108
|
-
const otherClient = await call(`${baseUrl}/v1/demo/page-sessions`, {
|
|
109
|
-
method: "POST",
|
|
110
|
-
headers: { ...jsonHeaders(null, PUBLIC_DEMO_ORIGIN), "x-forwarded-for": "203.0.113.2" },
|
|
111
|
-
body: "{}",
|
|
112
|
-
});
|
|
113
|
-
assert.equal(otherClient.response.status, 201);
|
|
114
|
-
});
|
|
115
|
-
|
|
116
|
-
test("server-owned options cannot be overridden through options.gateway", () => {
|
|
117
|
-
assert.throws(
|
|
118
|
-
() => createSurfaceServer({
|
|
119
|
-
config: {
|
|
120
|
-
tenants: [{
|
|
121
|
-
id: "demo",
|
|
122
|
-
origins: [ORIGIN],
|
|
123
|
-
}],
|
|
124
|
-
},
|
|
125
|
-
adminToken: ADMIN,
|
|
126
|
-
gateway: { publicBaseUrl: "https://attacker.example" },
|
|
127
|
-
}),
|
|
128
|
-
/cannot override/,
|
|
129
|
-
);
|
|
130
|
-
});
|
|
131
|
-
|
|
132
|
-
async function call(url, options = {}) {
|
|
133
|
-
const response = await fetch(url, options);
|
|
134
|
-
const text = await response.text();
|
|
135
|
-
let body;
|
|
136
|
-
try {
|
|
137
|
-
body = text ? JSON.parse(text) : null;
|
|
138
|
-
} catch {
|
|
139
|
-
body = text;
|
|
140
|
-
}
|
|
141
|
-
return { response, body };
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
function jsonHeaders(token, origin) {
|
|
145
|
-
return {
|
|
146
|
-
"content-type": "application/json",
|
|
147
|
-
...(token ? { authorization: `Bearer ${token}` } : {}),
|
|
148
|
-
...(origin ? { origin } : {}),
|
|
149
|
-
};
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
function createInbox(ws) {
|
|
153
|
-
const messages = [];
|
|
154
|
-
const waiters = [];
|
|
155
|
-
ws.on("message", (raw) => {
|
|
156
|
-
const message = JSON.parse(raw.toString());
|
|
157
|
-
const waiterIndex = waiters.findIndex((waiter) => waiter.predicate(message));
|
|
158
|
-
if (waiterIndex >= 0) {
|
|
159
|
-
const [waiter] = waiters.splice(waiterIndex, 1);
|
|
160
|
-
clearTimeout(waiter.timer);
|
|
161
|
-
waiter.resolve(message);
|
|
162
|
-
return;
|
|
163
|
-
}
|
|
164
|
-
messages.push(message);
|
|
165
|
-
});
|
|
166
|
-
return {
|
|
167
|
-
waitFor(predicate, timeoutMs = 2_000) {
|
|
168
|
-
const index = messages.findIndex(predicate);
|
|
169
|
-
if (index >= 0) return Promise.resolve(messages.splice(index, 1)[0]);
|
|
170
|
-
return new Promise((resolve, reject) => {
|
|
171
|
-
const waiter = { predicate, resolve, timer: null };
|
|
172
|
-
waiter.timer = setTimeout(() => {
|
|
173
|
-
const index = waiters.indexOf(waiter);
|
|
174
|
-
if (index >= 0) waiters.splice(index, 1);
|
|
175
|
-
reject(new Error("timed out waiting for WebSocket message"));
|
|
176
|
-
}, timeoutMs);
|
|
177
|
-
waiters.push(waiter);
|
|
178
|
-
});
|
|
179
|
-
},
|
|
180
|
-
};
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
test("HTTP + WebSocket + remote MCP complete a describe/invoke/approval loop", async (t) => {
|
|
184
|
-
const { server, baseUrl } = await startServer();
|
|
185
|
-
t.after(async () => {
|
|
186
|
-
await server.stop();
|
|
187
|
-
});
|
|
188
|
-
|
|
189
|
-
const health = await call(`${baseUrl}/health`);
|
|
190
|
-
assert.equal(health.response.status, 200);
|
|
191
|
-
assert.equal(health.body.ok, true);
|
|
192
|
-
|
|
193
|
-
const created = await call(`${baseUrl}/v1/page-sessions`, {
|
|
194
|
-
method: "POST",
|
|
195
|
-
headers: jsonHeaders(ADMIN),
|
|
196
|
-
body: JSON.stringify({ tenantId: "demo", userId: "user-1", scope: ["add_task", "delete@task"] }),
|
|
197
|
-
});
|
|
198
|
-
assert.equal(created.response.status, 201);
|
|
199
|
-
|
|
200
|
-
const ws = new WebSocket(`${baseUrl.replace(/^http/, "ws")}/surface?token=${created.body.token}`, { origin: ORIGIN });
|
|
201
|
-
const inbox = createInbox(ws);
|
|
202
|
-
await once(ws, "open");
|
|
203
|
-
ws.send(JSON.stringify({ type: "hello", protocol: "pling.bridge/0.1", app: "transport-test" }));
|
|
204
|
-
assert.equal((await inbox.waitFor((message) => message.type === "hello_ack")).protocol, "pling.bridge/0.1");
|
|
205
|
-
|
|
206
|
-
const pairing = await call(`${baseUrl}/v1/pairings`, {
|
|
207
|
-
method: "POST",
|
|
208
|
-
headers: jsonHeaders(null, ORIGIN),
|
|
209
|
-
body: JSON.stringify({ pageToken: created.body.token }),
|
|
210
|
-
});
|
|
211
|
-
assert.equal(pairing.response.status, 201);
|
|
212
|
-
assert.equal(pairing.body.tenantId, "demo");
|
|
213
|
-
|
|
214
|
-
const claimed = await call(`${baseUrl}/v1/agent-sessions`, {
|
|
215
|
-
method: "POST",
|
|
216
|
-
headers: jsonHeaders(),
|
|
217
|
-
body: JSON.stringify({ code: pairing.body.code, agentId: "codex-transport-test", scope: ["add_task"] }),
|
|
218
|
-
});
|
|
219
|
-
assert.equal(claimed.response.status, 201);
|
|
220
|
-
const opened = await inbox.waitFor((message) => message.type === "agent_session" && message.action === "opened");
|
|
221
|
-
assert.deepEqual(opened.session.scope, ["add_task"]);
|
|
222
|
-
|
|
223
|
-
const unauthorized = await call(`${baseUrl}/mcp`, {
|
|
224
|
-
method: "POST",
|
|
225
|
-
headers: jsonHeaders(),
|
|
226
|
-
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "ping" }),
|
|
227
|
-
});
|
|
228
|
-
assert.equal(unauthorized.response.status, 401);
|
|
229
|
-
|
|
230
|
-
const mcpHeaders = jsonHeaders(claimed.body.token);
|
|
231
|
-
const describeCall = call(`${baseUrl}/mcp`, {
|
|
232
|
-
method: "POST",
|
|
233
|
-
headers: mcpHeaders,
|
|
234
|
-
body: JSON.stringify({
|
|
235
|
-
jsonrpc: "2.0",
|
|
236
|
-
id: 1,
|
|
237
|
-
method: "tools/call",
|
|
238
|
-
params: { name: "pling_surface_describe", arguments: {} },
|
|
239
|
-
}),
|
|
240
|
-
});
|
|
241
|
-
const describeEnvelope = await inbox.waitFor((message) => message.type === "request");
|
|
242
|
-
assert.equal(describeEnvelope.request.method, "describe");
|
|
243
|
-
ws.send(
|
|
244
|
-
JSON.stringify({
|
|
245
|
-
type: "response",
|
|
246
|
-
requestId: describeEnvelope.requestId,
|
|
247
|
-
response: {
|
|
248
|
-
protocol: "pling.surface/0.1",
|
|
249
|
-
id: describeEnvelope.requestId,
|
|
250
|
-
ok: true,
|
|
251
|
-
result: { app: "transport-test", capabilities: [], context: { records: [] } },
|
|
252
|
-
},
|
|
253
|
-
}),
|
|
254
|
-
);
|
|
255
|
-
const described = await describeCall;
|
|
256
|
-
assert.equal(described.response.status, 200);
|
|
257
|
-
assert.match(described.body.result.content[0].text, /transport-test/);
|
|
258
|
-
|
|
259
|
-
const invokeCall = call(`${baseUrl}/mcp`, {
|
|
260
|
-
method: "POST",
|
|
261
|
-
headers: mcpHeaders,
|
|
262
|
-
body: JSON.stringify({
|
|
263
|
-
jsonrpc: "2.0",
|
|
264
|
-
id: 2,
|
|
265
|
-
method: "tools/call",
|
|
266
|
-
params: { name: "pling_surface_invoke", arguments: { capability: "add_task", input: { text: "Test" } } },
|
|
267
|
-
}),
|
|
268
|
-
});
|
|
269
|
-
const invokeEnvelope = await inbox.waitFor((message) => message.type === "request");
|
|
270
|
-
ws.send(
|
|
271
|
-
JSON.stringify({
|
|
272
|
-
type: "approval_pending",
|
|
273
|
-
requestId: invokeEnvelope.requestId,
|
|
274
|
-
approval: { description: "Add one task", kind: "write" },
|
|
275
|
-
}),
|
|
276
|
-
);
|
|
277
|
-
const pending = await invokeCall;
|
|
278
|
-
assert.match(pending.body.result.content[0].text, /pending|Waiting for user confirmation/);
|
|
279
|
-
assert.match(pending.body.result.content[0].text, new RegExp(invokeEnvelope.requestId));
|
|
280
|
-
|
|
281
|
-
const statusCall = call(`${baseUrl}/mcp`, {
|
|
282
|
-
method: "POST",
|
|
283
|
-
headers: mcpHeaders,
|
|
284
|
-
body: JSON.stringify({
|
|
285
|
-
jsonrpc: "2.0",
|
|
286
|
-
id: 3,
|
|
287
|
-
method: "tools/call",
|
|
288
|
-
params: { name: "pling_surface_approval_status", arguments: { requestId: invokeEnvelope.requestId } },
|
|
289
|
-
}),
|
|
290
|
-
});
|
|
291
|
-
const pendingStatus = await statusCall;
|
|
292
|
-
assert.match(pendingStatus.body.result.content[0].text, /pending_approval/);
|
|
293
|
-
|
|
294
|
-
ws.send(
|
|
295
|
-
JSON.stringify({
|
|
296
|
-
type: "response",
|
|
297
|
-
requestId: invokeEnvelope.requestId,
|
|
298
|
-
response: {
|
|
299
|
-
protocol: "pling.surface/0.1",
|
|
300
|
-
id: invokeEnvelope.requestId,
|
|
301
|
-
ok: true,
|
|
302
|
-
result: { result: { ok: true } },
|
|
303
|
-
},
|
|
304
|
-
}),
|
|
305
|
-
);
|
|
306
|
-
const finalStatus = await call(`${baseUrl}/mcp`, {
|
|
307
|
-
method: "POST",
|
|
308
|
-
headers: mcpHeaders,
|
|
309
|
-
body: JSON.stringify({
|
|
310
|
-
jsonrpc: "2.0",
|
|
311
|
-
id: 4,
|
|
312
|
-
method: "tools/call",
|
|
313
|
-
params: { name: "pling_surface_approval_status", arguments: { requestId: invokeEnvelope.requestId } },
|
|
314
|
-
}),
|
|
315
|
-
});
|
|
316
|
-
assert.match(finalStatus.body.result.content[0].text, /"state": "done"/);
|
|
317
|
-
|
|
318
|
-
const sessions = await call(`${baseUrl}/admin/sessions`, { headers: { authorization: `Bearer ${ADMIN}` } });
|
|
319
|
-
assert.equal(sessions.body.pageSessions.length, 1);
|
|
320
|
-
assert.equal(sessions.body.agentSessions.length, 1);
|
|
321
|
-
|
|
322
|
-
const revoke = await call(`${baseUrl}/admin/agent-sessions/${claimed.body.agentSessionId}`, {
|
|
323
|
-
method: "DELETE",
|
|
324
|
-
headers: { authorization: `Bearer ${ADMIN}` },
|
|
325
|
-
});
|
|
326
|
-
assert.equal(revoke.body.revokedAt >= 0, true);
|
|
327
|
-
|
|
328
|
-
ws.close();
|
|
329
|
-
if (ws.readyState !== WebSocket.CLOSED) await once(ws, "close");
|
|
330
|
-
});
|
|
331
|
-
|
|
332
|
-
test("page pairing requires the page Origin and an open WebSocket", async (t) => {
|
|
333
|
-
const { server, baseUrl } = await startServer();
|
|
334
|
-
t.after(async () => {
|
|
335
|
-
await server.stop();
|
|
336
|
-
});
|
|
337
|
-
|
|
338
|
-
const created = await call(`${baseUrl}/v1/page-sessions`, {
|
|
339
|
-
method: "POST",
|
|
340
|
-
headers: jsonHeaders(ADMIN),
|
|
341
|
-
body: JSON.stringify({ tenantId: "demo", userId: "user-1", scope: ["add_task"] }),
|
|
342
|
-
});
|
|
343
|
-
const noOrigin = await call(`${baseUrl}/v1/pairings`, {
|
|
344
|
-
method: "POST",
|
|
345
|
-
headers: jsonHeaders(),
|
|
346
|
-
body: JSON.stringify({ pageToken: created.body.token }),
|
|
347
|
-
});
|
|
348
|
-
assert.equal(noOrigin.response.status, 403);
|
|
349
|
-
|
|
350
|
-
const wrongOrigin = await call(`${baseUrl}/v1/pairings`, {
|
|
351
|
-
method: "POST",
|
|
352
|
-
headers: jsonHeaders(null, "https://evil.example"),
|
|
353
|
-
body: JSON.stringify({ pageToken: created.body.token }),
|
|
354
|
-
});
|
|
355
|
-
assert.equal(wrongOrigin.response.status, 403);
|
|
356
|
-
|
|
357
|
-
const notConnected = await call(`${baseUrl}/v1/pairings`, {
|
|
358
|
-
method: "POST",
|
|
359
|
-
headers: jsonHeaders(null, ORIGIN),
|
|
360
|
-
body: JSON.stringify({ pageToken: created.body.token }),
|
|
361
|
-
});
|
|
362
|
-
assert.equal(notConnected.response.status, 409);
|
|
363
|
-
});
|
|
File without changes
|
/package/{dist/NOTICE → NOTICE}
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|