@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.
@@ -1,372 +0,0 @@
1
- import assert from "node:assert/strict";
2
- import test from "node:test";
3
- import { normalizeConfig } from "../src/config.mjs";
4
- import { createSurfaceGateway } from "../src/gateway.mjs";
5
-
6
- const ORIGIN = "https://demo.example";
7
- const PUBLIC_DEMO_ORIGIN = "https://plingjs.dev";
8
- const ADMIN = "admin-token-for-tests";
9
-
10
- class MockSocket {
11
- readyState = 1;
12
- messages = [];
13
- closed = null;
14
-
15
- send(value) {
16
- this.messages.push(JSON.parse(value));
17
- }
18
-
19
- close(code, reason) {
20
- this.readyState = 3;
21
- this.closed = { code, reason };
22
- }
23
- }
24
-
25
- function setup(overrides = {}) {
26
- let clock = 1_000_000;
27
- const gateway = createSurfaceGateway({
28
- adminToken: ADMIN,
29
- publicBaseUrl: "https://surface.example",
30
- config: normalizeConfig({
31
- tenants: [
32
- {
33
- id: "demo",
34
- displayName: "Demo",
35
- origins: [ORIGIN, PUBLIC_DEMO_ORIGIN],
36
- maxPageSessionTtlMs: 60_000,
37
- maxAgentSessionTtlMs: 60_000,
38
- publicDemo: {
39
- origins: [PUBLIC_DEMO_ORIGIN],
40
- scope: ["todo.overview", "todo.add"],
41
- pageSessionTtlMs: 30_000,
42
- maxSessionsPerWindow: 2,
43
- windowMs: 60_000,
44
- maxActiveSessions: 2,
45
- },
46
- },
47
- ],
48
- }),
49
- now: () => clock,
50
- requestTimeoutMs: 20,
51
- approvalTimeoutMs: 500,
52
- ...overrides,
53
- });
54
- return {
55
- gateway,
56
- advance(ms) {
57
- clock += ms;
58
- },
59
- };
60
- }
61
-
62
- function connectedPage(gateway, scope = ["add_task", "delete@task"]) {
63
- const created = gateway.createPageSession({ tenantId: "demo", userId: "user-1", scope });
64
- const session = gateway.authenticatePage(created.token, ORIGIN);
65
- const socket = new MockSocket();
66
- gateway.attachPage(session, socket, ORIGIN);
67
- return { created, session, socket };
68
- }
69
-
70
- async function pairedAgent(gateway, page, scope) {
71
- const pairing = gateway.createPairing({ pageToken: page.created.token });
72
- return gateway.claimPairing({ code: pairing.code, agentId: "codex-test", ...(scope ? { scope } : {}) });
73
- }
74
-
75
- function takeRequest(socket) {
76
- const message = socket.messages.find((item) => item.type === "request");
77
- assert.ok(message, "page should receive a request envelope");
78
- socket.messages = socket.messages.filter((item) => item !== message);
79
- return message;
80
- }
81
-
82
- test("page sessions require an allowed Origin and explicit scope", () => {
83
- const { gateway } = setup();
84
- assert.throws(
85
- () => gateway.createPageSession({ tenantId: "demo", userId: "u" }),
86
- (error) => error.code === "SCOPE_REQUIRED",
87
- );
88
- const created = gateway.createPageSession({ tenantId: "demo", userId: "u", scope: ["add_task"] });
89
- assert.throws(
90
- () => gateway.authenticatePage(created.token, "https://evil.example"),
91
- (error) => error.code === "ORIGIN_NOT_ALLOWED",
92
- );
93
- });
94
-
95
- test("public demo sessions are origin-bound, fixed-scope, and rate limited", () => {
96
- const { gateway, advance } = setup();
97
-
98
- const created = gateway.createPublicDemoPageSession({ tenantId: "demo", origin: PUBLIC_DEMO_ORIGIN, clientKey: "203.0.113.10" });
99
- assert.deepEqual(gateway.authenticatePage(created.token, PUBLIC_DEMO_ORIGIN).allowedScope, ["todo.overview", "todo.add"]);
100
- assert.equal(created.expiresAt - 1_000_000, 30_000);
101
-
102
- assert.throws(
103
- () => gateway.createPublicDemoPageSession({ tenantId: "demo", origin: ORIGIN, clientKey: "203.0.113.10" }),
104
- (error) => error.code === "ORIGIN_NOT_ALLOWED",
105
- );
106
-
107
- gateway.createPublicDemoPageSession({ tenantId: "demo", origin: PUBLIC_DEMO_ORIGIN, clientKey: "203.0.113.10" });
108
- assert.throws(
109
- () => gateway.createPublicDemoPageSession({ tenantId: "demo", origin: PUBLIC_DEMO_ORIGIN, clientKey: "203.0.113.10" }),
110
- (error) => error.code === "RATE_LIMITED",
111
- );
112
-
113
- advance(61_000);
114
- const next = gateway.createPublicDemoPageSession({ tenantId: "demo", origin: PUBLIC_DEMO_ORIGIN, clientKey: "203.0.113.10" });
115
- assert.ok(next.pageSessionId);
116
- });
117
-
118
- test("pairing is single-use and an Agent scope cannot exceed the page session", async () => {
119
- const { gateway } = setup();
120
- const page = connectedPage(gateway, ["add_task"]);
121
- const pairing = gateway.createPairing({ pageToken: page.created.token });
122
-
123
- assert.throws(
124
- () => gateway.claimPairing({ code: pairing.code, agentId: "agent", scope: ["delete@task"] }),
125
- (error) => error.code === "SCOPE_NOT_ALLOWED",
126
- );
127
-
128
- const agent = gateway.claimPairing({ code: pairing.code, agentId: "agent", scope: ["add_task"] });
129
- assert.deepEqual(agent.scope, ["add_task"]);
130
- assert.ok(page.socket.messages.some((item) => item.type === "agent_session" && item.action === "opened"));
131
- assert.throws(() => gateway.claimPairing({ code: pairing.code, agentId: "second" }), (error) => error.code === "PAIRING_INVALID");
132
- });
133
-
134
- test("describe and invoke forward opaque Surface envelopes and keep the final response", async () => {
135
- const { gateway } = setup();
136
- const page = connectedPage(gateway);
137
- const agent = await pairedAgent(gateway, page, ["add_task"]);
138
-
139
- const describe = gateway.describe(agent.token);
140
- const describeEnvelope = takeRequest(page.socket);
141
- assert.equal(describeEnvelope.request.method, "describe");
142
- gateway.notePageMessage(page.session, {
143
- type: "response",
144
- requestId: describeEnvelope.requestId,
145
- response: {
146
- protocol: "pling.surface/0.1",
147
- id: describeEnvelope.requestId,
148
- ok: true,
149
- result: { app: "demo", capabilities: [], context: { records: [] } },
150
- },
151
- });
152
- assert.equal((await describe).ok, true);
153
-
154
- const invoke = gateway.invoke(agent.token, "add_task", { text: "Ship the gateway" });
155
- const invokeEnvelope = takeRequest(page.socket);
156
- assert.deepEqual(invokeEnvelope.request, {
157
- protocol: "pling.surface/0.1",
158
- id: invokeEnvelope.requestId,
159
- method: "invoke",
160
- capability: "add_task",
161
- input: { text: "Ship the gateway" },
162
- });
163
- gateway.notePageMessage(page.session, {
164
- type: "response",
165
- requestId: invokeEnvelope.requestId,
166
- response: {
167
- protocol: "pling.surface/0.1",
168
- id: invokeEnvelope.requestId,
169
- ok: true,
170
- result: { result: { ok: true } },
171
- },
172
- });
173
- assert.equal((await invoke).state, "done");
174
- assert.equal(gateway.status(agent.token, invokeEnvelope.requestId).response.ok, true);
175
- });
176
-
177
- test("pending approval is not killed by the ordinary request timeout and can finish later", async () => {
178
- const { gateway } = setup({ requestTimeoutMs: 10, approvalTimeoutMs: 500 });
179
- const page = connectedPage(gateway);
180
- const agent = await pairedAgent(gateway, page, ["delete@task"]);
181
-
182
- const invocation = gateway.invoke(agent.token, "delete@task", { record: "t1" });
183
- const envelope = takeRequest(page.socket);
184
- gateway.notePageMessage(page.session, {
185
- type: "approval_pending",
186
- requestId: envelope.requestId,
187
- approval: { description: "Delete task", kind: "destructive", args: { record: "t1" } },
188
- });
189
-
190
- const first = await invocation;
191
- assert.equal(first.state, "pending_approval");
192
- await new Promise((resolve) => setTimeout(resolve, 30));
193
- assert.equal(gateway.status(agent.token, envelope.requestId).state, "pending_approval");
194
-
195
- gateway.notePageMessage(page.session, {
196
- type: "response",
197
- requestId: envelope.requestId,
198
- response: {
199
- protocol: "pling.surface/0.1",
200
- id: envelope.requestId,
201
- ok: true,
202
- result: { result: { ok: true } },
203
- },
204
- });
205
- const final = gateway.status(agent.token, envelope.requestId);
206
- assert.equal(final.state, "done");
207
- assert.equal(final.response.ok, true);
208
- });
209
-
210
- test("an ordinary page timeout is reported once and remains queryable", async () => {
211
- const { gateway } = setup({ requestTimeoutMs: 10 });
212
- const page = connectedPage(gateway);
213
- const agent = await pairedAgent(gateway, page, ["add_task"]);
214
-
215
- const invocation = gateway.invoke(agent.token, "add_task", { text: "late" });
216
- const envelope = takeRequest(page.socket);
217
- const result = await invocation;
218
- assert.equal(result.state, "done");
219
- assert.equal(result.response.error.code, "GATEWAY_TIMEOUT");
220
- assert.equal(gateway.status(agent.token, envelope.requestId).response.error.code, "GATEWAY_TIMEOUT");
221
- });
222
-
223
- test("a stale socket close cannot detach the replacement page connection", () => {
224
- const { gateway } = setup();
225
- const page = connectedPage(gateway);
226
- const oldSocket = page.socket;
227
- const newSocket = new MockSocket();
228
- gateway.attachPage(page.session, newSocket, ORIGIN);
229
-
230
- assert.equal(oldSocket.closed.code, 4001);
231
- gateway.disconnectPage(page.session, "old socket closed", oldSocket);
232
- assert.equal(gateway.listSessions().pageSessions[0].connected, true);
233
-
234
- gateway.disconnectPage(page.session, "real close", newSocket);
235
- assert.equal(gateway.listSessions().pageSessions[0].connected, false);
236
- });
237
-
238
- test("revoking an Agent session fails inflight calls and removes its token", async () => {
239
- const { gateway } = setup({ requestTimeoutMs: 1_000 });
240
- const page = connectedPage(gateway);
241
- const agent = await pairedAgent(gateway, page, ["add_task"]);
242
- const invocation = gateway.invoke(agent.token, "add_task", { text: "wait" });
243
- const envelope = takeRequest(page.socket);
244
-
245
- gateway.revokeAgentSession(agent.agentSessionId, "test revoke");
246
- const result = await invocation;
247
- assert.equal(result.response.error.code, "SESSION_REVOKED");
248
- assert.throws(() => gateway.authenticateAgent(agent.token), (error) => error.code === "AGENT_SESSION_INVALID");
249
- assert.throws(() => gateway.status(agent.token, envelope.requestId), (error) => error.code === "AGENT_SESSION_INVALID");
250
- });
251
-
252
- test("page and Agent TTLs cannot exceed the tenant limits", async () => {
253
- const { gateway } = setup();
254
- const page = connectedPage(gateway);
255
- assert.equal(page.created.expiresAt - 1_000_000, 60_000);
256
-
257
- const agent = await pairedAgent(gateway, page, ["add_task"]);
258
- assert.equal(agent.expiresAt - 1_000_000, 60_000);
259
- });
260
-
261
- test("page expiry immediately invalidates the bound Agent token", async () => {
262
- const { gateway, advance } = setup();
263
- const page = connectedPage(gateway);
264
- const agent = await pairedAgent(gateway, page, ["add_task"]);
265
-
266
- advance(61_000);
267
- assert.throws(() => gateway.authenticateAgent(agent.token), (error) => error.code === "AGENT_SESSION_INVALID");
268
- assert.equal(gateway.listSessions().pageSessions.length, 0);
269
- });
270
-
271
- test("Gateway scope matching follows Surface prefix semantics", async () => {
272
- const { gateway } = setup();
273
-
274
- const familyPage = connectedPage(gateway, ["delete@"]);
275
- const familyAgent = await pairedAgent(gateway, familyPage, ["delete@task"]);
276
- assert.deepEqual(familyAgent.scope, ["delete@task"]);
277
- await assert.rejects(
278
- gateway.invoke(familyAgent.token, "delete@other", {}),
279
- (error) => error.code === "SCOPE_NOT_ALLOWED",
280
- );
281
-
282
- const concretePage = connectedPage(gateway, ["delete@task"]);
283
- await assert.rejects(
284
- pairedAgent(gateway, concretePage, ["delete@"]),
285
- (error) => error.code === "SCOPE_NOT_ALLOWED",
286
- );
287
-
288
- const wildcardPage = connectedPage(gateway, ["*"]);
289
- const wildcardAgent = await pairedAgent(gateway, wildcardPage, ["delete@task"]);
290
- assert.deepEqual(wildcardAgent.scope, ["delete@task"]);
291
- await assert.rejects(
292
- pairedAgent(gateway, connectedPage(gateway, ["add_task"]), ["*"]),
293
- (error) => error.code === "SCOPE_NOT_ALLOWED",
294
- );
295
- });
296
-
297
- test("pending approval results survive the ordinary result retention window", async () => {
298
- const { gateway, advance } = setup({ resultTtlMs: 5, approvalTimeoutMs: 1_000 });
299
- const page = connectedPage(gateway);
300
- const agent = await pairedAgent(gateway, page, ["add_task"]);
301
-
302
- const invocation = gateway.invoke(agent.token, "add_task", { text: "slow confirmation" });
303
- const envelope = takeRequest(page.socket);
304
- gateway.notePageMessage(page.session, {
305
- type: "approval_pending",
306
- requestId: envelope.requestId,
307
- approval: { description: "Confirm the task", kind: "write" },
308
- });
309
- assert.equal((await invocation).state, "pending_approval");
310
-
311
- advance(20);
312
- gateway.expire();
313
- assert.equal(gateway.status(agent.token, envelope.requestId).state, "pending_approval");
314
-
315
- gateway.notePageMessage(page.session, {
316
- type: "response",
317
- requestId: envelope.requestId,
318
- response: {
319
- protocol: "pling.surface/0.1",
320
- id: envelope.requestId,
321
- ok: true,
322
- result: { result: { ok: true } },
323
- },
324
- });
325
- assert.equal(gateway.status(agent.token, envelope.requestId).state, "done");
326
- });
327
-
328
- test("pairing claims are rate limited per client and recover after the window", async () => {
329
- const { gateway, advance } = setup({ maxPairingClaimsPerWindow: 2, pairingClaimWindowMs: 1_000 });
330
- const page = connectedPage(gateway);
331
- const pairing = gateway.createPairing({ pageToken: page.created.token });
332
-
333
- for (let attempt = 0; attempt < 2; attempt += 1) {
334
- assert.throws(
335
- () => gateway.claimPairing({ code: "BAD", agentId: "agent", clientKey: "203.0.113.20" }),
336
- (error) => error.code === "PAIRING_INVALID",
337
- );
338
- }
339
- assert.throws(
340
- () => gateway.claimPairing({ code: pairing.code, agentId: "agent", clientKey: "203.0.113.20" }),
341
- (error) => error.code === "RATE_LIMITED",
342
- );
343
-
344
- advance(1_001);
345
- const agent = gateway.claimPairing({ code: pairing.code, agentId: "agent", clientKey: "203.0.113.20" });
346
- assert.ok(agent.token);
347
- });
348
-
349
- test("shutdown fails inflight calls and removes all credentials", async () => {
350
- const { gateway } = setup({ requestTimeoutMs: 1_000 });
351
- const page = connectedPage(gateway);
352
- const agent = await pairedAgent(gateway, page, ["add_task"]);
353
- const invocation = gateway.invoke(agent.token, "add_task", { text: "pending" });
354
- takeRequest(page.socket);
355
-
356
- gateway.shutdown();
357
- const result = await invocation;
358
- assert.equal(result.response.error.code, "GATEWAY_SHUTDOWN");
359
- assert.throws(() => gateway.authenticateAgent(agent.token), (error) => error.code === "AGENT_SESSION_INVALID");
360
- assert.deepEqual(gateway.listSessions().pageSessions, []);
361
- });
362
-
363
- test("pairing audit events never contain the bearer pairing code", () => {
364
- const events = [];
365
- const { gateway } = setup({ onAudit: (event) => events.push(event) });
366
- const page = connectedPage(gateway);
367
- const pairing = gateway.createPairing({ pageToken: page.created.token });
368
- const created = events.find((event) => event.type === "pairing.created");
369
-
370
- assert.ok(created?.pairingId);
371
- assert.equal(JSON.stringify(created).includes(pairing.code), false);
372
- });
package/test/mcp.test.mjs DELETED
@@ -1,88 +0,0 @@
1
- import assert from "node:assert/strict";
2
- import test from "node:test";
3
- import { createMcpHandler } from "../src/mcp.mjs";
4
- import { PACKAGE_VERSION } from "../src/version.mjs";
5
-
6
- function fakeGateway() {
7
- const calls = [];
8
- return {
9
- calls,
10
- async describe(token) {
11
- calls.push(["describe", token]);
12
- if (token !== "agent-token") throw new Error("bad token");
13
- return {
14
- ok: true,
15
- result: { protocol: "pling.surface/0.1", capabilities: [], context: { records: [] } },
16
- };
17
- },
18
- async invoke(token, capability, input) {
19
- calls.push(["invoke", token, capability, input]);
20
- return {
21
- state: "pending_approval",
22
- requestId: "plg_req_1",
23
- capability,
24
- approval: { description: "Delete one task" },
25
- };
26
- },
27
- status(token, requestId) {
28
- calls.push(["status", token, requestId]);
29
- return { state: "pending_approval", requestId };
30
- },
31
- };
32
- }
33
-
34
- function rpc(id, method, params = {}) {
35
- return { jsonrpc: "2.0", id, method, params };
36
- }
37
-
38
- test("MCP binds the bearer token outside model-visible tool arguments", async () => {
39
- const gateway = fakeGateway();
40
- const mcp = createMcpHandler(gateway);
41
- const response = await mcp.handle(
42
- rpc(1, "tools/call", { name: "pling_surface_describe", arguments: { _agentToken: "attacker-controlled" } }),
43
- { agentToken: "agent-token" },
44
- );
45
-
46
- assert.equal(response.result.isError, undefined);
47
- assert.deepEqual(gateway.calls.at(-1), ["describe", "agent-token"]);
48
- assert.match(response.result.content[0].text, /pling\.surface/);
49
- });
50
-
51
- test("pending approval is returned as a state, never as an approval tool", async () => {
52
- const gateway = fakeGateway();
53
- const mcp = createMcpHandler(gateway);
54
- const listed = await mcp.handle(rpc(1, "tools/list"), { agentToken: "agent-token" });
55
- assert.deepEqual(
56
- listed.result.tools.map((tool) => tool.name),
57
- ["pling_surface_describe", "pling_surface_invoke", "pling_surface_approval_status"],
58
- );
59
-
60
- const invoked = await mcp.handle(
61
- rpc(2, "tools/call", { name: "pling_surface_invoke", arguments: { capability: "delete@task", input: { record: "t1" } } }),
62
- { agentToken: "agent-token" },
63
- );
64
- assert.match(invoked.result.content[0].text, /Waiting for user confirmation/);
65
- assert.match(invoked.result.content[0].text, /Do not claim approval/);
66
-
67
- const status = await mcp.handle(
68
- rpc(3, "tools/call", { name: "pling_surface_approval_status", arguments: { requestId: "plg_req_1" } }),
69
- { agentToken: "agent-token" },
70
- );
71
- assert.match(status.result.content[0].text, /pending_approval/);
72
- });
73
-
74
- test("notifications do not produce an MCP response", async () => {
75
- const mcp = createMcpHandler(fakeGateway());
76
- assert.equal(await mcp.handle({ jsonrpc: "2.0", method: "notifications/initialized" }, { agentToken: "agent-token" }), null);
77
- });
78
-
79
- test("initialize reports the package version and rejects malformed ids", async () => {
80
- const mcp = createMcpHandler(fakeGateway());
81
- const initialized = await mcp.handle(rpc(1, "initialize", { protocolVersion: "2025-03-26" }), {
82
- agentToken: "agent-token",
83
- });
84
- assert.equal(initialized.result.serverInfo.version, PACKAGE_VERSION);
85
-
86
- const invalid = await mcp.handle({ jsonrpc: "2.0", id: { bad: true }, method: "ping" }, {});
87
- assert.equal(invalid.error.code, -32600);
88
- });