@plingjs/surface-gateway 0.0.0-stage → 0.1.0-alpha.3

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/server.mjs ADDED
@@ -0,0 +1,462 @@
1
+ import { createServer, STATUS_CODES } from "node:http";
2
+ import { timingSafeEqual } from "node:crypto";
3
+ import { WebSocket, WebSocketServer } from "ws";
4
+ import { normalizeConfig } from "./config.mjs";
5
+ import { GatewayError, asGatewayError } from "./errors.mjs";
6
+ import { createSurfaceGateway } from "./gateway.mjs";
7
+ import { createMcpHandler } from "./mcp.mjs";
8
+
9
+ const MAX_BODY_BYTES = 64 * 1024;
10
+ const MAX_MCP_BODY_BYTES = 1024 * 1024;
11
+ const MAX_WS_PAYLOAD_BYTES = 512 * 1024;
12
+ const HELLO_TIMEOUT_MS = 5_000;
13
+ const HEARTBEAT_MS = 30_000;
14
+ const GATEWAY_OPTION_KEYS = new Set([
15
+ "requestTimeoutMs",
16
+ "approvalTimeoutMs",
17
+ "pageSessionTtlMs",
18
+ "pairingTtlMs",
19
+ "agentSessionTtlMs",
20
+ "resultTtlMs",
21
+ "revokedSessionRetentionMs",
22
+ "maxPairingClaimsPerWindow",
23
+ "pairingClaimWindowMs",
24
+ "now",
25
+ "onAudit",
26
+ ]);
27
+
28
+ /**
29
+ * HTTP + WebSocket transport for the Surface gateway.
30
+ *
31
+ * The gateway core owns sessions and capability forwarding. This module only
32
+ * authenticates transport requests, applies Origin policy, and maps errors to
33
+ * HTTP / JSON-RPC responses. It never executes a page capability itself.
34
+ */
35
+ export function createSurfaceServer(options = {}) {
36
+ if (!options.config) throw new TypeError("config is required.");
37
+ const config = normalizeConfig(options.config);
38
+ const adminToken = requiredSecret(options.adminToken, "adminToken");
39
+ const host = options.host ?? "127.0.0.1";
40
+ const requestedPort = validatePort(options.port ?? 5219);
41
+ const trustProxy = options.trustProxy === true;
42
+ const allowedOrigins = new Set(config.tenants.flatMap((tenant) => tenant.origins));
43
+ const logger = options.logger ?? ((message) => process.stderr.write(`${message}\n`));
44
+ const demoTenantId = options.demoTenantId?.trim() || "demo";
45
+ const gatewayOptions = sanitizeGatewayOptions(options.gateway);
46
+ let gateway = null;
47
+ let mcp = null;
48
+ let baseUrl = null;
49
+ let heartbeat = null;
50
+
51
+ const server = createServer((request, response) => {
52
+ void route(request, response).catch((error) => {
53
+ const failure = asGatewayError(error);
54
+ if (failure.status >= 500) {
55
+ logger(`[surface-gateway] ${request.method ?? "?"} ${request.url ?? "/"} failed: ${failure.cause ?? failure.message}`);
56
+ }
57
+ if (!response.headersSent) sendError(response, failure);
58
+ else response.destroy();
59
+ });
60
+ });
61
+
62
+ const sockets = new WebSocketServer({ noServer: true, maxPayload: MAX_WS_PAYLOAD_BYTES });
63
+
64
+ server.on("upgrade", (request, socket, head) => {
65
+ void upgrade(request, socket, head).catch((error) => {
66
+ const failure = asGatewayError(error);
67
+ writeUpgradeFailure(socket, failure.status, failure.message);
68
+ });
69
+ });
70
+
71
+ async function upgrade(request, socket, head) {
72
+ if (!gateway) return writeUpgradeFailure(socket, 503, "Service Unavailable");
73
+ const url = requestUrl(request);
74
+ if (url.pathname !== "/surface") return writeUpgradeFailure(socket, 404, "Not Found");
75
+
76
+ let page;
77
+ try {
78
+ page = gateway.authenticatePage(url.searchParams.get("token"), request.headers.origin);
79
+ } catch (error) {
80
+ const failure = asGatewayError(error);
81
+ return writeUpgradeFailure(socket, failure.status, failure.message);
82
+ }
83
+
84
+ sockets.handleUpgrade(request, socket, head, (ws) => {
85
+ acceptPage(ws, page, request.headers.origin);
86
+ });
87
+ }
88
+
89
+ function acceptPage(ws, page, origin) {
90
+ let attached = false;
91
+ let closed = false;
92
+ ws.isAlive = true;
93
+ ws.on("pong", () => {
94
+ ws.isAlive = true;
95
+ });
96
+
97
+ const helloTimer = setTimeout(() => {
98
+ if (!attached) ws.close(4008, "hello timeout");
99
+ }, HELLO_TIMEOUT_MS);
100
+ helloTimer.unref?.();
101
+
102
+ ws.on("message", (raw, isBinary) => {
103
+ if (closed) return;
104
+ if (isBinary) return ws.close(1003, "text messages only");
105
+ let message;
106
+ try {
107
+ message = JSON.parse(raw.toString());
108
+ } catch {
109
+ return ws.close(1003, "invalid json");
110
+ }
111
+ if (!message || typeof message !== "object" || Array.isArray(message)) {
112
+ return ws.close(1003, "invalid message");
113
+ }
114
+
115
+ if (!attached) {
116
+ if (message.type !== "hello" || message.protocol !== "pling.bridge/0.1") {
117
+ return ws.close(1002, "bridge hello required");
118
+ }
119
+ clearTimeout(helloTimer);
120
+ try {
121
+ gateway.attachPage(page, ws, origin);
122
+ } catch {
123
+ return ws.close(1008, "page rejected");
124
+ }
125
+ attached = true;
126
+ ws.send(JSON.stringify({ type: "hello_ack", protocol: "pling.bridge/0.1" }));
127
+ return;
128
+ }
129
+
130
+ if (message.type === "hello") return ws.close(1002, "duplicate hello");
131
+ gateway.notePageMessage(page, message);
132
+ });
133
+
134
+ ws.on("close", (code, reason) => {
135
+ closed = true;
136
+ clearTimeout(helloTimer);
137
+ if (!attached) return;
138
+ gateway.disconnectPage(page, reason?.toString() || `page closed (${code})`, ws);
139
+ });
140
+ ws.on("error", () => {});
141
+ }
142
+
143
+ async function route(request, response) {
144
+ setCommonHeaders(response);
145
+ const url = requestUrl(request);
146
+ const origin = request.headers.origin;
147
+ const cors = origin && allowedOrigins.has(origin);
148
+ if (origin && cors) {
149
+ response.setHeader("access-control-allow-origin", origin);
150
+ response.setHeader("vary", "Origin");
151
+ response.setHeader("access-control-allow-headers", "authorization, content-type, mcp-protocol-version");
152
+ response.setHeader("access-control-allow-methods", "GET, POST, DELETE, OPTIONS");
153
+ }
154
+
155
+ if (request.method === "OPTIONS") {
156
+ if (!cors) return sendJson(response, 403, { error: { code: "ORIGIN_NOT_ALLOWED", message: "Origin not allowed." } });
157
+ response.writeHead(204);
158
+ return response.end();
159
+ }
160
+
161
+ if (request.method === "GET" && url.pathname === "/health") {
162
+ return sendJson(response, 200, {
163
+ ok: true,
164
+ service: "pling-surface-gateway",
165
+ version: gateway?.version ?? null,
166
+ now: new Date().toISOString(),
167
+ });
168
+ }
169
+
170
+ if (request.method === "GET") {
171
+ const discovery = url.pathname.match(/^\/v1\/discovery\/([^/]+)$/);
172
+ if (discovery) {
173
+ return sendJson(response, 200, gateway.discover(decodeURIComponent(discovery[1])));
174
+ }
175
+ }
176
+
177
+ if (request.method === "POST" && url.pathname === "/v1/page-sessions") {
178
+ requireAdmin(request);
179
+ return sendJson(response, 201, gateway.createPageSession(await readJson(request)));
180
+ }
181
+
182
+ if (request.method === "POST" && url.pathname === "/v1/demo/page-sessions") {
183
+ if (!origin) throw new GatewayError("ORIGIN_REQUIRED", "Public demo requests require an Origin header.", 403);
184
+ await readJson(request);
185
+ return sendJson(
186
+ response,
187
+ 201,
188
+ gateway.createPublicDemoPageSession({
189
+ tenantId: demoTenantId,
190
+ origin,
191
+ clientKey: clientKeyOf(request, trustProxy),
192
+ }),
193
+ );
194
+ }
195
+
196
+ if (request.method === "POST" && url.pathname === "/v1/pairings") {
197
+ const body = await readJson(request);
198
+ if (!origin) throw new GatewayError("ORIGIN_REQUIRED", "Page requests require an Origin header.", 403);
199
+ gateway.authenticatePage(body.pageToken, origin);
200
+ return sendJson(response, 201, gateway.createPairing(body));
201
+ }
202
+
203
+ if (request.method === "POST" && url.pathname === "/v1/agent-sessions") {
204
+ return sendJson(
205
+ response,
206
+ 201,
207
+ gateway.claimPairing({
208
+ ...(await readJson(request)),
209
+ clientKey: clientKeyOf(request, trustProxy),
210
+ }),
211
+ );
212
+ }
213
+
214
+ if (request.method === "POST" && url.pathname === "/v1/agent-sessions/revoke") {
215
+ const body = await readJson(request);
216
+ if (!origin) throw new GatewayError("ORIGIN_REQUIRED", "Page requests require an Origin header.", 403);
217
+ const page = gateway.authenticatePage(body.pageToken, origin);
218
+ const sessions = gateway.listSessions().agentSessions;
219
+ const agent = sessions.find((item) => item.id === body.agentSessionId);
220
+ if (!agent || agent.pageSessionId !== page.id) {
221
+ throw new GatewayError("AGENT_SESSION_NOT_FOUND", "Agent session does not belong to this page.", 404);
222
+ }
223
+ return sendJson(response, 200, gateway.revokeAgentSession(agent.id, "page owner revoked"));
224
+ }
225
+
226
+ if (request.method === "GET" && url.pathname === "/admin/sessions") {
227
+ requireAdmin(request);
228
+ return sendJson(response, 200, gateway.listSessions());
229
+ }
230
+
231
+ if (request.method === "DELETE" && url.pathname.startsWith("/admin/page-sessions/")) {
232
+ requireAdmin(request);
233
+ return sendJson(response, 200, gateway.revokePageSession(decodeURIComponent(url.pathname.slice(21)), "admin revoked"));
234
+ }
235
+
236
+ if (request.method === "DELETE" && url.pathname.startsWith("/admin/agent-sessions/")) {
237
+ requireAdmin(request);
238
+ return sendJson(response, 200, gateway.revokeAgentSession(decodeURIComponent(url.pathname.slice(22)), "admin revoked"));
239
+ }
240
+
241
+ if (request.method === "POST" && url.pathname === "/mcp") {
242
+ const agentToken = requireBearer(request);
243
+ gateway.authenticateAgent(agentToken);
244
+ const body = await readJson(request, MAX_MCP_BODY_BYTES);
245
+ const result = await mcp.handle(body, { agentToken });
246
+ if (result === null) {
247
+ response.writeHead(202);
248
+ return response.end();
249
+ }
250
+ return sendJson(response, 200, result);
251
+ }
252
+
253
+ throw new GatewayError("NOT_FOUND", `No route for ${request.method} ${url.pathname}.`, 404);
254
+ }
255
+
256
+ function requireAdmin(request) {
257
+ const token = requireBearer(request);
258
+ if (!constantEqual(token, adminToken)) {
259
+ throw new GatewayError("ADMIN_UNAUTHORIZED", "Admin token is invalid.", 401);
260
+ }
261
+ }
262
+
263
+ async function start() {
264
+ if (gateway) throw new Error("Surface server is already started.");
265
+ await new Promise((resolve, reject) => {
266
+ const onError = (error) => {
267
+ server.off("listening", onListening);
268
+ reject(error);
269
+ };
270
+ const onListening = () => {
271
+ server.off("error", onError);
272
+ resolve();
273
+ };
274
+ server.once("error", onError);
275
+ server.once("listening", onListening);
276
+ server.listen(requestedPort, host);
277
+ });
278
+ const address = server.address();
279
+ const port = typeof address === "object" && address ? address.port : requestedPort;
280
+ baseUrl = (options.publicBaseUrl ?? `http://${displayHost(host)}:${port}`).replace(/\/$/, "");
281
+ try {
282
+ gateway = createSurfaceGateway({
283
+ ...gatewayOptions,
284
+ config,
285
+ publicBaseUrl: baseUrl,
286
+ });
287
+ mcp = createMcpHandler(gateway, { version: gateway.version });
288
+ } catch (error) {
289
+ await closeHttpServer();
290
+ throw error;
291
+ }
292
+ heartbeat = setInterval(() => {
293
+ for (const ws of sockets.clients) {
294
+ if (ws.isAlive === false) {
295
+ ws.terminate();
296
+ continue;
297
+ }
298
+ ws.isAlive = false;
299
+ try {
300
+ ws.ping();
301
+ } catch {
302
+ ws.terminate();
303
+ }
304
+ }
305
+ }, HEARTBEAT_MS);
306
+ heartbeat.unref?.();
307
+ logger(`[surface-gateway] listening on ${baseUrl}`);
308
+ return { baseUrl, port };
309
+ }
310
+
311
+ async function stop() {
312
+ if (heartbeat) clearInterval(heartbeat);
313
+ heartbeat = null;
314
+ for (const ws of sockets.clients) ws.terminate();
315
+ await new Promise((resolve) => sockets.close(() => resolve()));
316
+ await closeHttpServer();
317
+ gateway?.shutdown();
318
+ gateway = null;
319
+ mcp = null;
320
+ baseUrl = null;
321
+ }
322
+
323
+ async function closeHttpServer() {
324
+ if (!server.listening) return;
325
+ await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())));
326
+ }
327
+
328
+ return {
329
+ get gateway() {
330
+ return gateway;
331
+ },
332
+ get baseUrl() {
333
+ return baseUrl;
334
+ },
335
+ start,
336
+ stop,
337
+ };
338
+ }
339
+
340
+ function sanitizeGatewayOptions(value) {
341
+ if (value === undefined || value === null) return {};
342
+ if (typeof value !== "object" || Array.isArray(value)) {
343
+ throw new TypeError("gateway must be an object.");
344
+ }
345
+ for (const reserved of ["config", "adminToken", "publicBaseUrl"]) {
346
+ if (Object.prototype.hasOwnProperty.call(value, reserved)) {
347
+ throw new TypeError(`gateway.${reserved} cannot override the server option.`);
348
+ }
349
+ }
350
+ return Object.fromEntries(Object.entries(value).filter(([key]) => GATEWAY_OPTION_KEYS.has(key)));
351
+ }
352
+
353
+ function requiredSecret(value, name) {
354
+ if (typeof value !== "string" || !value.trim()) throw new TypeError(`${name} is required.`);
355
+ return value.trim();
356
+ }
357
+
358
+ function validatePort(value) {
359
+ if (!Number.isInteger(value) || value < 0 || value > 65535) {
360
+ throw new TypeError("port must be an integer between 0 and 65535.");
361
+ }
362
+ return value;
363
+ }
364
+
365
+ function clientKeyOf(request, trustProxy) {
366
+ if (trustProxy) {
367
+ const forwarded = request.headers["x-forwarded-for"];
368
+ const first = Array.isArray(forwarded) ? forwarded[0] : forwarded;
369
+ const forwardedAddress = normalizeAddress(first?.split(",")[0]);
370
+ if (forwardedAddress) return forwardedAddress;
371
+ }
372
+ return normalizeAddress(request.socket.remoteAddress) ?? "unknown";
373
+ }
374
+
375
+ function normalizeAddress(value) {
376
+ if (typeof value !== "string") return null;
377
+ const text = value.trim();
378
+ if (!text || text.length > 128) return null;
379
+ return text.startsWith("::ffff:") ? text.slice(7) : text;
380
+ }
381
+
382
+ function requestUrl(request) {
383
+ try {
384
+ return new URL(request.url ?? "/", `http://${request.headers.host ?? "localhost"}`);
385
+ } catch {
386
+ throw new GatewayError("BAD_REQUEST", "Invalid request URL.", 400);
387
+ }
388
+ }
389
+
390
+ async function readJson(request, limit = MAX_BODY_BYTES) {
391
+ const type = String(request.headers["content-type"] ?? "").split(";", 1)[0].trim().toLowerCase();
392
+ if (type !== "application/json") {
393
+ throw new GatewayError("UNSUPPORTED_MEDIA_TYPE", "Content-Type must be application/json.", 415);
394
+ }
395
+ const chunks = [];
396
+ let size = 0;
397
+ for await (const chunk of request) {
398
+ size += chunk.length;
399
+ if (size > limit) throw new GatewayError("BODY_TOO_LARGE", "Request body is too large.", 413);
400
+ chunks.push(chunk);
401
+ }
402
+ if (size === 0) throw new GatewayError("INVALID_JSON", "Request body must not be empty.", 400);
403
+ try {
404
+ const value = JSON.parse(Buffer.concat(chunks).toString("utf8"));
405
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("not an object");
406
+ return value;
407
+ } catch {
408
+ throw new GatewayError("INVALID_JSON", "Request body must be a JSON object.", 400);
409
+ }
410
+ }
411
+
412
+ function requireBearer(request) {
413
+ const header = String(request.headers.authorization ?? "");
414
+ const match = header.match(/^Bearer\s+(.+)$/i);
415
+ if (!match || !match[1].trim()) {
416
+ throw new GatewayError("UNAUTHORIZED", "A Bearer token is required.", 401);
417
+ }
418
+ return match[1].trim();
419
+ }
420
+
421
+ function setCommonHeaders(response) {
422
+ response.setHeader("cache-control", "no-store");
423
+ response.setHeader("x-content-type-options", "nosniff");
424
+ response.setHeader("referrer-policy", "no-referrer");
425
+ response.setHeader("content-security-policy", "default-src 'none'; frame-ancestors 'none'");
426
+ }
427
+
428
+ function sendJson(response, status, value) {
429
+ if (response.writableEnded) return;
430
+ const body = JSON.stringify(value);
431
+ response.writeHead(status, {
432
+ "content-type": "application/json; charset=utf-8",
433
+ "content-length": Buffer.byteLength(body),
434
+ });
435
+ response.end(body);
436
+ }
437
+
438
+ function sendError(response, error) {
439
+ sendJson(response, error.status ?? 500, {
440
+ error: { code: error.code ?? "INTERNAL_ERROR", message: error.message },
441
+ });
442
+ }
443
+
444
+ function writeUpgradeFailure(socket, status, message) {
445
+ if (socket.destroyed) return;
446
+ const safeMessage = String(message).replace(/[\r\n]+/g, " ");
447
+ const statusText = STATUS_CODES[status] ?? "Error";
448
+ socket.end(
449
+ `HTTP/1.1 ${status} ${statusText}\r\nConnection: close\r\nContent-Type: text/plain; charset=utf-8\r\nContent-Length: ${Buffer.byteLength(safeMessage)}\r\n\r\n${safeMessage}`,
450
+ );
451
+ }
452
+
453
+ function constantEqual(left, right) {
454
+ const a = Buffer.from(String(left));
455
+ const b = Buffer.from(String(right));
456
+ if (a.length !== b.length) return false;
457
+ return timingSafeEqual(a, b);
458
+ }
459
+
460
+ function displayHost(host) {
461
+ return host === "0.0.0.0" || host === "::" ? "127.0.0.1" : host;
462
+ }
package/version.mjs ADDED
@@ -0,0 +1 @@
1
+ export const PACKAGE_VERSION = "0.1.0-alpha.3";