@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/gateway.mjs ADDED
@@ -0,0 +1,935 @@
1
+ import { randomBytes, randomUUID } from "node:crypto";
2
+ import { normalizeConfig } from "./config.mjs";
3
+ import { GatewayError } from "./errors.mjs";
4
+ import { PACKAGE_VERSION } from "./version.mjs";
5
+
6
+ export const BRIDGE_PROTOCOL = "pling.bridge/0.1";
7
+ export const SURFACE_PROTOCOL = "pling.surface/0.1";
8
+
9
+ const OPEN = 1;
10
+ const MAX_ID_LENGTH = 256;
11
+ const MAX_SCOPE_ENTRIES = 256;
12
+
13
+ /**
14
+ * Multi-tenant Surface transport/control plane.
15
+ *
16
+ * This object deliberately knows nothing about page DOM or business actions.
17
+ * It authenticates a page connection, binds an Agent session to that page, and
18
+ * forwards opaque `describe` / `invoke` envelopes. Permission remains in the
19
+ * page's Surface session and the business backend.
20
+ */
21
+ export function createSurfaceGateway(options = {}) {
22
+ const config = normalizeConfig(options.config);
23
+ const now = options.now ?? Date.now;
24
+ const requestTimeoutMs = positiveOption(options.requestTimeoutMs, 20_000, "requestTimeoutMs");
25
+ const approvalTimeoutMs = positiveOption(options.approvalTimeoutMs, 15 * 60_000, "approvalTimeoutMs");
26
+ const pageSessionTtlMs = positiveOption(options.pageSessionTtlMs, 24 * 60 * 60_000, "pageSessionTtlMs");
27
+ const pairingTtlMs = positiveOption(options.pairingTtlMs, 2 * 60_000, "pairingTtlMs");
28
+ const agentSessionTtlMs = positiveOption(options.agentSessionTtlMs, 24 * 60 * 60_000, "agentSessionTtlMs");
29
+ const resultTtlMs = positiveOption(options.resultTtlMs, 10 * 60_000, "resultTtlMs");
30
+ const revokedSessionRetentionMs = positiveOption(
31
+ options.revokedSessionRetentionMs,
32
+ Math.max(resultTtlMs, 10 * 60_000),
33
+ "revokedSessionRetentionMs",
34
+ );
35
+ const maxPairingClaimsPerWindow = positiveOption(
36
+ options.maxPairingClaimsPerWindow,
37
+ 30,
38
+ "maxPairingClaimsPerWindow",
39
+ );
40
+ const pairingClaimWindowMs = positiveOption(
41
+ options.pairingClaimWindowMs,
42
+ 10 * 60_000,
43
+ "pairingClaimWindowMs",
44
+ );
45
+ const publicBaseUrl = normalizePublicBaseUrl(options.publicBaseUrl);
46
+ const onAudit = options.onAudit ?? (() => {});
47
+
48
+ const tenants = new Map(config.tenants.map((tenant) => [tenant.id, tenant]));
49
+ const maxDemoWindowMs = Math.max(
50
+ 0,
51
+ ...config.tenants.map((tenant) => tenant.publicDemo?.windowMs ?? 0),
52
+ );
53
+ const pageSessions = new Map();
54
+ const pageByToken = new Map();
55
+ const agentSessions = new Map();
56
+ const agentByToken = new Map();
57
+ const pairings = new Map();
58
+ const requests = new Map();
59
+ const demoWindows = new Map();
60
+ const claimWindows = new Map();
61
+ let shuttingDown = false;
62
+
63
+ function audit(type, data = {}) {
64
+ try {
65
+ Promise.resolve(
66
+ onAudit({
67
+ type,
68
+ at: new Date(now()).toISOString(),
69
+ ...data,
70
+ }),
71
+ ).catch(() => {});
72
+ } catch {
73
+ // Audit must not change the outcome of a protocol call.
74
+ }
75
+ }
76
+
77
+ function tenantOf(id) {
78
+ const tenant = tenants.get(id);
79
+ if (!tenant) throw new GatewayError("UNKNOWN_TENANT", `Unknown tenant: ${id}`, 404);
80
+ return tenant;
81
+ }
82
+
83
+ function createPageSession(input = {}) {
84
+ assertRunning();
85
+ expire();
86
+ const tenant = tenantOf(requiredString(input.tenantId, "tenantId", 128));
87
+ const userId = requiredString(input.userId, "userId", MAX_ID_LENGTH);
88
+ const scope = normalizeScope(input.scope);
89
+ if (scope.length === 0) {
90
+ throw new GatewayError("SCOPE_REQUIRED", "A page session must declare at least one scope entry.", 400);
91
+ }
92
+ const ttlMs = clampTtl(input.ttlMs, tenant.maxPageSessionTtlMs, pageSessionTtlMs);
93
+ const token = `plg_page_${randomBytes(24).toString("base64url")}`;
94
+ const session = {
95
+ id: `ps_${randomUUID()}`,
96
+ tenantId: tenant.id,
97
+ userId,
98
+ allowedScope: scope,
99
+ token,
100
+ createdAt: now(),
101
+ expiresAt: now() + ttlMs,
102
+ connectedAt: null,
103
+ socket: null,
104
+ app: null,
105
+ activeAgentSessionId: null,
106
+ };
107
+ pageSessions.set(session.id, session);
108
+ pageByToken.set(token, session.id);
109
+ audit("page_session.created", {
110
+ tenantId: tenant.id,
111
+ pageSessionId: session.id,
112
+ userId,
113
+ scope,
114
+ expiresAt: session.expiresAt,
115
+ });
116
+ return {
117
+ pageSessionId: session.id,
118
+ token: session.token,
119
+ expiresAt: session.expiresAt,
120
+ tenantId: tenant.id,
121
+ surfaceUrl: websocketUrl(publicBaseUrl),
122
+ pairingUrl: `${publicBaseUrl}/v1/pairings`,
123
+ agentSessionsUrl: `${publicBaseUrl}/v1/agent-sessions`,
124
+ mcpUrl: `${publicBaseUrl}/mcp`,
125
+ scope: [...scope],
126
+ };
127
+ }
128
+
129
+ /**
130
+ * Create a page session for a configured public demo.
131
+ *
132
+ * The browser never receives the admin token. The tenant config owns the
133
+ * scope, Origin allowlist, TTL, and rate limit, so a caller cannot widen its
134
+ * own access or create a session for another tenant.
135
+ */
136
+ function createPublicDemoPageSession(input = {}) {
137
+ assertRunning();
138
+ expire();
139
+ const tenant = tenantOf(requiredString(input.tenantId, "tenantId", 128));
140
+ const demo = tenant.publicDemo;
141
+ if (!demo) throw new GatewayError("PUBLIC_DEMO_DISABLED", "Public demo sessions are not enabled for this tenant.", 404);
142
+ const origin = requiredString(input.origin, "origin", MAX_ID_LENGTH);
143
+ if (!demo.origins.includes(origin)) {
144
+ throw new GatewayError("ORIGIN_NOT_ALLOWED", "This Origin is not allowed for the public demo.", 403);
145
+ }
146
+ const clientKey = optionalString(input.clientKey, 128) ?? "unknown";
147
+ const windowKey = `${tenant.id}:${clientKey}`;
148
+ const current = now();
149
+ const recent = activeWindow(demoWindows.get(windowKey), demo.windowMs, current);
150
+ if (recent.length >= demo.maxSessionsPerWindow) {
151
+ throw new GatewayError("RATE_LIMITED", "Too many public demo sessions. Try again later.", 429);
152
+ }
153
+ const active = [...pageSessions.values()].filter(
154
+ (page) => page.tenantId === tenant.id && page.expiresAt > current && page.userId.startsWith(`${demo.userIdPrefix}:`),
155
+ );
156
+ if (active.length >= demo.maxActiveSessions) {
157
+ throw new GatewayError("DEMO_CAPACITY", "The public demo is at capacity. Try again later.", 429);
158
+ }
159
+ const session = createPageSession({
160
+ tenantId: tenant.id,
161
+ userId: `${demo.userIdPrefix}:${randomUUID()}`,
162
+ scope: demo.scope,
163
+ ttlMs: demo.pageSessionTtlMs,
164
+ });
165
+ demoWindows.set(windowKey, [...recent, current]);
166
+ audit("page_session.public_demo_created", {
167
+ tenantId: tenant.id,
168
+ pageSessionId: session.pageSessionId,
169
+ origin,
170
+ scope: demo.scope,
171
+ expiresAt: session.expiresAt,
172
+ });
173
+ return session;
174
+ }
175
+
176
+ function authenticatePage(token, origin) {
177
+ expire();
178
+ const sessionId = pageByToken.get(String(token ?? ""));
179
+ const session = sessionId ? pageSessions.get(sessionId) : null;
180
+ if (!session || session.expiresAt <= now()) {
181
+ throw new GatewayError("PAGE_SESSION_INVALID", "Page session is invalid or expired.", 401);
182
+ }
183
+ const tenant = tenantOf(session.tenantId);
184
+ if (!origin || !tenant.origins.includes(origin)) {
185
+ throw new GatewayError("ORIGIN_NOT_ALLOWED", "The page Origin is not allowed for this tenant.", 403);
186
+ }
187
+ return session;
188
+ }
189
+
190
+ function attachPage(session, socket, origin) {
191
+ if (!session || session.expiresAt <= now() || !pageSessions.has(session.id)) {
192
+ throw new GatewayError("PAGE_SESSION_INVALID", "Page session is invalid or expired.", 401);
193
+ }
194
+ const tenant = tenantOf(session.tenantId);
195
+ if (!origin || !tenant.origins.includes(origin)) {
196
+ throw new GatewayError("ORIGIN_NOT_ALLOWED", "The page Origin is not allowed for this tenant.", 403);
197
+ }
198
+ if (session.socket && session.socket !== socket && session.socket.readyState === OPEN) {
199
+ session.socket.close(4001, "replaced by a newer page connection");
200
+ }
201
+ session.socket = socket;
202
+ session.connectedAt = now();
203
+ audit("page.connected", {
204
+ tenantId: session.tenantId,
205
+ pageSessionId: session.id,
206
+ userId: session.userId,
207
+ });
208
+ const agent = session.activeAgentSessionId ? agentSessions.get(session.activeAgentSessionId) : null;
209
+ if (agent && isValidAgent(agent)) sendAgentSession(session, "opened", agent);
210
+ }
211
+
212
+ function disconnectPage(session, reason = "page disconnected", socket = session?.socket) {
213
+ if (!session) return;
214
+ // A replaced socket can close after the new one is attached. Never let the
215
+ // stale close event tear down the live connection.
216
+ if (session.socket && session.socket !== socket) return;
217
+ session.socket = null;
218
+ session.connectedAt = null;
219
+ failPendingForPage(session.id, "PAGE_DISCONNECTED", reason);
220
+ audit("page.disconnected", {
221
+ tenantId: session.tenantId,
222
+ pageSessionId: session.id,
223
+ reason,
224
+ });
225
+ }
226
+
227
+ function createPairing(input = {}) {
228
+ assertRunning();
229
+ expire();
230
+ const pageToken = requiredString(input.pageToken, "pageToken", 512);
231
+ const sessionId = pageByToken.get(pageToken);
232
+ const session = sessionId ? pageSessions.get(sessionId) : null;
233
+ if (!session || session.expiresAt <= now()) {
234
+ throw new GatewayError("PAGE_SESSION_INVALID", "Page session is invalid or expired.", 401);
235
+ }
236
+ if (!isSocketOpen(session.socket)) {
237
+ throw new GatewayError("PAGE_NOT_CONNECTED", "The business page is not connected to the Surface Gateway.", 409);
238
+ }
239
+ const tenant = tenantOf(session.tenantId);
240
+ const ttlMs = clampTtl(input.ttlMs, pairingTtlMs, pairingTtlMs);
241
+ const code = uniquePairingCode();
242
+ const pairing = {
243
+ id: `pr_${randomUUID()}`,
244
+ code,
245
+ tenantId: session.tenantId,
246
+ pageSessionId: session.id,
247
+ createdAt: now(),
248
+ expiresAt: now() + ttlMs,
249
+ claimedAt: null,
250
+ };
251
+ pairings.set(code, pairing);
252
+ audit("pairing.created", {
253
+ tenantId: session.tenantId,
254
+ pageSessionId: session.id,
255
+ pairingId: pairing.id,
256
+ expiresAt: pairing.expiresAt,
257
+ });
258
+ return {
259
+ code,
260
+ tenantId: session.tenantId,
261
+ tenantName: tenant.displayName,
262
+ mcpUrl: `${publicBaseUrl}/mcp`,
263
+ expiresAt: pairing.expiresAt,
264
+ scope: [...session.allowedScope],
265
+ };
266
+ }
267
+
268
+ function claimPairing(input = {}) {
269
+ assertRunning();
270
+ expire();
271
+ const clientKey = optionalString(input.clientKey, 128) ?? "unknown";
272
+ const current = now();
273
+ const recent = activeWindow(claimWindows.get(clientKey), pairingClaimWindowMs, current);
274
+ if (recent.length >= maxPairingClaimsPerWindow) {
275
+ audit("pairing.claim_rate_limited", { clientKey });
276
+ throw new GatewayError("RATE_LIMITED", "Too many pairing attempts. Try again later.", 429);
277
+ }
278
+ claimWindows.set(clientKey, [...recent, current]);
279
+
280
+ try {
281
+ const code = requiredString(input.code, "code", 128).toUpperCase();
282
+ const agentId = requiredString(input.agentId, "agentId", MAX_ID_LENGTH);
283
+ const pairing = pairings.get(code);
284
+ if (!pairing || pairing.expiresAt <= current || pairing.claimedAt) {
285
+ throw new GatewayError("PAIRING_INVALID", "Pairing code is invalid, expired, or already used.", 404);
286
+ }
287
+ const page = pageSessions.get(pairing.pageSessionId);
288
+ if (!page || page.expiresAt <= current || !isSocketOpen(page.socket)) {
289
+ throw new GatewayError("PAGE_NOT_CONNECTED", "The business page is disconnected. Reopen it and pair again.", 409);
290
+ }
291
+ const active = page.activeAgentSessionId ? agentSessions.get(page.activeAgentSessionId) : null;
292
+ if (active && isValidAgent(active, current)) {
293
+ throw new GatewayError("AGENT_SESSION_ACTIVE", "This page already has an active Agent session.", 409);
294
+ }
295
+ const tenant = tenantOf(page.tenantId);
296
+ const requested = input.scope === undefined ? [...page.allowedScope] : normalizeScope(input.scope);
297
+ if (requested.length === 0) {
298
+ throw new GatewayError("SCOPE_REQUIRED", "At least one Agent scope entry is required.", 400);
299
+ }
300
+ if (!scopeSubset(requested, page.allowedScope)) {
301
+ throw new GatewayError("SCOPE_NOT_ALLOWED", "The requested scope exceeds the page session scope.", 403);
302
+ }
303
+ const ttlMs = clampTtl(input.ttlMs, tenant.maxAgentSessionTtlMs, agentSessionTtlMs);
304
+ const token = `plg_agent_${randomBytes(24).toString("base64url")}`;
305
+ const agent = {
306
+ id: `as_${randomUUID()}`,
307
+ token,
308
+ tenantId: page.tenantId,
309
+ pageSessionId: page.id,
310
+ agentId,
311
+ userId: page.userId,
312
+ scope: requested,
313
+ createdAt: current,
314
+ expiresAt: current + ttlMs,
315
+ revokedAt: null,
316
+ };
317
+ pairing.claimedAt = current;
318
+ pairings.delete(code);
319
+ agentSessions.set(agent.id, agent);
320
+ agentByToken.set(token, agent.id);
321
+ page.activeAgentSessionId = agent.id;
322
+ sendAgentSession(page, "opened", agent);
323
+ audit("agent_session.opened", {
324
+ tenantId: agent.tenantId,
325
+ pageSessionId: page.id,
326
+ agentSessionId: agent.id,
327
+ agentId,
328
+ userId: agent.userId,
329
+ scope: requested,
330
+ expiresAt: agent.expiresAt,
331
+ });
332
+ return {
333
+ agentSessionId: agent.id,
334
+ token,
335
+ tenantId: agent.tenantId,
336
+ pageSessionId: page.id,
337
+ agentId,
338
+ scope: agent.scope,
339
+ expiresAt: agent.expiresAt,
340
+ mcpUrl: `${publicBaseUrl}/mcp`,
341
+ };
342
+ } catch (error) {
343
+ if (error instanceof GatewayError) {
344
+ audit("pairing.claim_failed", {
345
+ clientKey,
346
+ code: error.code,
347
+ status: error.status,
348
+ });
349
+ }
350
+ throw error;
351
+ }
352
+ }
353
+
354
+ function authenticateAgent(token) {
355
+ expire();
356
+ const id = agentByToken.get(String(token ?? ""));
357
+ const agent = id ? agentSessions.get(id) : null;
358
+ if (!agent || !isValidAgent(agent)) {
359
+ throw new GatewayError("AGENT_SESSION_INVALID", "Agent session is invalid, revoked, or expired.", 401);
360
+ }
361
+ return agent;
362
+ }
363
+
364
+ function isValidAgent(agent, current = now()) {
365
+ if (!agent || agent.revokedAt || agent.expiresAt <= current) return false;
366
+ const page = pageSessions.get(agent.pageSessionId);
367
+ return Boolean(page && page.expiresAt > current && page.tenantId === agent.tenantId);
368
+ }
369
+
370
+ function revokeAgentSession(id, reason = "revoked") {
371
+ const agent = agentSessions.get(id);
372
+ if (!agent) throw new GatewayError("AGENT_SESSION_NOT_FOUND", "Agent session not found.", 404);
373
+ if (agent.revokedAt) return publicAgent(agent);
374
+ agent.revokedAt = now();
375
+ agentByToken.delete(agent.token);
376
+ const page = pageSessions.get(agent.pageSessionId);
377
+ if (page?.activeAgentSessionId === agent.id) page.activeAgentSessionId = null;
378
+ if (page) sendAgentSession(page, "closed", agent);
379
+ failPendingForAgent(agent.id, "SESSION_REVOKED", "Agent session was revoked.");
380
+ audit("agent_session.revoked", {
381
+ tenantId: agent.tenantId,
382
+ pageSessionId: agent.pageSessionId,
383
+ agentSessionId: agent.id,
384
+ agentId: agent.agentId,
385
+ reason,
386
+ });
387
+ return publicAgent(agent);
388
+ }
389
+
390
+ function revokePageSession(id, reason = "revoked") {
391
+ const page = pageSessions.get(id);
392
+ if (!page) throw new GatewayError("PAGE_SESSION_NOT_FOUND", "Page session not found.", 404);
393
+ const agents = agentsForPage(page.id);
394
+ for (const agent of agents) revokeAgentSession(agent.id, `page ${reason}`);
395
+ removePairingsForPage(page.id);
396
+ page.expiresAt = now();
397
+ if (page.socket) page.socket.close(4002, "page session revoked");
398
+ pageByToken.delete(page.token);
399
+ pageSessions.delete(page.id);
400
+ failPendingForPage(page.id, "PAGE_SESSION_REVOKED", "Page session was revoked.");
401
+ audit("page_session.revoked", {
402
+ tenantId: page.tenantId,
403
+ pageSessionId: page.id,
404
+ reason,
405
+ });
406
+ return { pageSessionId: page.id, revoked: true };
407
+ }
408
+
409
+ async function describe(agentToken) {
410
+ const agent = authenticateAgent(agentToken);
411
+ const result = await requestPage(agent, { method: "describe" });
412
+ return result.response;
413
+ }
414
+
415
+ async function invoke(agentToken, capability, input = {}) {
416
+ const agent = authenticateAgent(agentToken);
417
+ const capabilityId = requiredString(capability, "capability", MAX_ID_LENGTH);
418
+ if (!input || typeof input !== "object" || Array.isArray(input)) {
419
+ throw new GatewayError("INVALID_REQUEST", "input must be an object.", 400);
420
+ }
421
+ if (!scopeAllowsCapability(agent.scope, capabilityId)) {
422
+ throw new GatewayError("SCOPE_NOT_ALLOWED", "Capability is outside this Agent session scope.", 403);
423
+ }
424
+ return requestPage(agent, { method: "invoke", capability: capabilityId, input });
425
+ }
426
+
427
+ function status(agentToken, requestId) {
428
+ const agent = authenticateAgent(agentToken);
429
+ const entry = requests.get(String(requestId ?? ""));
430
+ if (!entry || entry.agentSessionId !== agent.id) {
431
+ return { state: "not_found", requestId: String(requestId ?? "") };
432
+ }
433
+ if (entry.response) return doneView(entry);
434
+ if (entry.approval) return pendingView(entry);
435
+ return { state: "waiting", requestId: entry.id, capability: entry.capability };
436
+ }
437
+
438
+ function requestPage(agent, payload) {
439
+ const page = pageSessions.get(agent.pageSessionId);
440
+ if (!page || page.expiresAt <= now()) {
441
+ throw new GatewayError("PAGE_SESSION_INVALID", "Page session is no longer available.", 410);
442
+ }
443
+ if (!isSocketOpen(page.socket)) {
444
+ throw new GatewayError("PAGE_NOT_CONNECTED", "The business page is not connected to the Surface Gateway.", 409);
445
+ }
446
+ const requestId = `plg_req_${randomUUID()}`;
447
+ const current = now();
448
+ const entry = {
449
+ id: requestId,
450
+ tenantId: agent.tenantId,
451
+ pageSessionId: page.id,
452
+ agentSessionId: agent.id,
453
+ capability: payload.capability,
454
+ createdAt: current,
455
+ updatedAt: current,
456
+ response: null,
457
+ approval: null,
458
+ resolveFirst: null,
459
+ timer: null,
460
+ };
461
+ requests.set(requestId, entry);
462
+ const promise = new Promise((resolve) => {
463
+ entry.resolveFirst = resolve;
464
+ });
465
+ entry.timer = setTimeout(() => {
466
+ if (!entry.response) {
467
+ entry.response = failure(requestId, "GATEWAY_TIMEOUT", "Timed out waiting for the page response.");
468
+ entry.updatedAt = now();
469
+ }
470
+ entry.approval = null;
471
+ entry.timer = null;
472
+ resolveFirst(entry);
473
+ }, requestTimeoutMs);
474
+ const envelope = {
475
+ protocol: SURFACE_PROTOCOL,
476
+ id: requestId,
477
+ method: payload.method,
478
+ ...(payload.capability ? { capability: payload.capability } : {}),
479
+ ...(payload.input === undefined ? {} : { input: payload.input }),
480
+ };
481
+ try {
482
+ page.socket.send(JSON.stringify({ type: "request", requestId, request: envelope }));
483
+ } catch (error) {
484
+ entry.response = failure(
485
+ requestId,
486
+ "PAGE_SEND_FAILED",
487
+ error instanceof Error ? error.message : String(error),
488
+ );
489
+ entry.updatedAt = now();
490
+ clearEntryTimer(entry);
491
+ resolveFirst(entry);
492
+ }
493
+ audit("surface.request", {
494
+ tenantId: agent.tenantId,
495
+ pageSessionId: page.id,
496
+ agentSessionId: agent.id,
497
+ requestId,
498
+ method: payload.method,
499
+ capability: payload.capability,
500
+ });
501
+ return promise;
502
+ }
503
+
504
+ function notePageMessage(page, message) {
505
+ if (!message || typeof message !== "object") return;
506
+ if (message.type === "approval_pending" && typeof message.requestId === "string") {
507
+ const entry = requests.get(message.requestId);
508
+ if (!entry || entry.pageSessionId !== page.id || entry.response) return;
509
+ entry.approval = normalizeApproval(message.approval, now);
510
+ entry.updatedAt = now();
511
+ clearEntryTimer(entry);
512
+ // Approval is a first response, but the operation can still finish later.
513
+ // Keep the entry alive long enough for status polling after confirmation.
514
+ entry.timer = setTimeout(() => {
515
+ if (!entry.response) {
516
+ entry.response = failure(entry.id, "GATEWAY_APPROVAL_TIMEOUT", "Timed out waiting for approval or a final result.");
517
+ entry.updatedAt = now();
518
+ }
519
+ entry.timer = null;
520
+ }, approvalTimeoutMs);
521
+ audit("surface.pending_approval", {
522
+ tenantId: entry.tenantId,
523
+ pageSessionId: entry.pageSessionId,
524
+ agentSessionId: entry.agentSessionId,
525
+ requestId: entry.id,
526
+ capability: entry.capability,
527
+ });
528
+ resolveFirst(entry);
529
+ return;
530
+ }
531
+ if (message.type === "response" && typeof message.requestId === "string") {
532
+ const entry = requests.get(message.requestId);
533
+ if (!entry || entry.pageSessionId !== page.id) return;
534
+ entry.response = message.response ?? failure(entry.id, "INVALID_PAGE_RESPONSE", "The page returned an empty response.");
535
+ entry.updatedAt = now();
536
+ clearEntryTimer(entry);
537
+ audit("surface.response", {
538
+ tenantId: entry.tenantId,
539
+ pageSessionId: entry.pageSessionId,
540
+ agentSessionId: entry.agentSessionId,
541
+ requestId: entry.id,
542
+ capability: entry.capability,
543
+ ok: entry.response?.ok === true,
544
+ });
545
+ resolveFirst(entry);
546
+ }
547
+ }
548
+
549
+ function resolveFirst(entry) {
550
+ if (!entry.resolveFirst) return;
551
+ const resolve = entry.resolveFirst;
552
+ entry.resolveFirst = null;
553
+ resolve(doneView(entry));
554
+ }
555
+
556
+ function doneView(entry) {
557
+ const base = {
558
+ state: entry.response ? "done" : entry.approval ? "pending_approval" : "waiting",
559
+ requestId: entry.id,
560
+ capability: entry.capability,
561
+ };
562
+ if (entry.response) return { ...base, response: entry.response };
563
+ if (entry.approval) return { ...base, approval: entry.approval };
564
+ return base;
565
+ }
566
+
567
+ function pendingView(entry) {
568
+ return {
569
+ state: "pending_approval",
570
+ requestId: entry.id,
571
+ capability: entry.capability,
572
+ approval: entry.approval,
573
+ };
574
+ }
575
+
576
+ function failPendingForPage(pageSessionId, code, message) {
577
+ for (const entry of requests.values()) {
578
+ if (entry.pageSessionId !== pageSessionId || entry.response) continue;
579
+ entry.response = failure(entry.id, code, message);
580
+ entry.updatedAt = now();
581
+ clearEntryTimer(entry);
582
+ resolveFirst(entry);
583
+ }
584
+ }
585
+
586
+ function failPendingForAgent(agentSessionId, code, message) {
587
+ for (const entry of requests.values()) {
588
+ if (entry.agentSessionId !== agentSessionId || entry.response) continue;
589
+ entry.response = failure(entry.id, code, message);
590
+ entry.updatedAt = now();
591
+ clearEntryTimer(entry);
592
+ resolveFirst(entry);
593
+ }
594
+ }
595
+
596
+ function discover(tenantId) {
597
+ const tenant = tenantOf(requiredString(tenantId, "tenantId", 128));
598
+ return {
599
+ protocol: SURFACE_PROTOCOL,
600
+ bridge: BRIDGE_PROTOCOL,
601
+ service: `pling-surface-gateway/${PACKAGE_VERSION}`,
602
+ tenant: { id: tenant.id, displayName: tenant.displayName },
603
+ endpoints: {
604
+ surface: websocketUrl(publicBaseUrl),
605
+ mcp: `${publicBaseUrl}/mcp`,
606
+ pairing: `${publicBaseUrl}/v1/pairings`,
607
+ },
608
+ note: "Discovery metadata is a local draft; pairing and authorization remain site-specific.",
609
+ };
610
+ }
611
+
612
+ function listSessions() {
613
+ expire();
614
+ return {
615
+ tenants: [...tenants.values()].map(({ id, displayName, origins }) => ({ id, displayName, origins })),
616
+ pageSessions: [...pageSessions.values()].map((page) => ({
617
+ id: page.id,
618
+ tenantId: page.tenantId,
619
+ userId: page.userId,
620
+ connected: isSocketOpen(page.socket),
621
+ scope: [...page.allowedScope],
622
+ expiresAt: page.expiresAt,
623
+ activeAgentSessionId: page.activeAgentSessionId,
624
+ })),
625
+ agentSessions: [...agentSessions.values()].map(publicAgent),
626
+ };
627
+ }
628
+
629
+ function publicAgent(agent) {
630
+ return {
631
+ id: agent.id,
632
+ tenantId: agent.tenantId,
633
+ pageSessionId: agent.pageSessionId,
634
+ agentId: agent.agentId,
635
+ userId: agent.userId,
636
+ scope: [...agent.scope],
637
+ expiresAt: agent.expiresAt,
638
+ revokedAt: agent.revokedAt,
639
+ };
640
+ }
641
+
642
+ function sendAgentSession(page, action, agent) {
643
+ if (!isSocketOpen(page.socket)) return;
644
+ page.socket.send(
645
+ JSON.stringify({
646
+ type: "agent_session",
647
+ action,
648
+ session: {
649
+ sessionId: agent.id,
650
+ agentId: agent.agentId,
651
+ userId: agent.userId,
652
+ scope: [...agent.scope],
653
+ expiresAt: agent.expiresAt,
654
+ ...(agent.revokedAt ? { revokedAt: agent.revokedAt } : {}),
655
+ },
656
+ }),
657
+ );
658
+ }
659
+
660
+ function agentsForPage(pageSessionId) {
661
+ return [...agentSessions.values()].filter(
662
+ (agent) => agent.pageSessionId === pageSessionId && !agent.revokedAt,
663
+ );
664
+ }
665
+
666
+ function removePairingsForPage(pageSessionId) {
667
+ for (const pairing of pairings.values()) {
668
+ if (pairing.pageSessionId === pageSessionId) pairings.delete(pairing.code);
669
+ }
670
+ }
671
+
672
+ function retirePage(page, code, message, reason) {
673
+ const current = now();
674
+ for (const agent of agentsForPage(page.id)) revokeAgentSession(agent.id, `page ${reason}`);
675
+ removePairingsForPage(page.id);
676
+ page.expiresAt = current;
677
+ if (page.socket) page.socket.close(4003, "page session expired");
678
+ pageByToken.delete(page.token);
679
+ pageSessions.delete(page.id);
680
+ failPendingForPage(page.id, code, message);
681
+ audit("page_session.expired", {
682
+ tenantId: page.tenantId,
683
+ pageSessionId: page.id,
684
+ });
685
+ }
686
+
687
+ function expireAgent(agent, current) {
688
+ agent.revokedAt = current;
689
+ agentByToken.delete(agent.token);
690
+ const page = pageSessions.get(agent.pageSessionId);
691
+ if (page?.activeAgentSessionId === agent.id) page.activeAgentSessionId = null;
692
+ if (page) sendAgentSession(page, "closed", agent);
693
+ failPendingForAgent(agent.id, "SESSION_EXPIRED", "Agent session expired.");
694
+ audit("agent_session.expired", {
695
+ tenantId: agent.tenantId,
696
+ pageSessionId: agent.pageSessionId,
697
+ agentSessionId: agent.id,
698
+ agentId: agent.agentId,
699
+ });
700
+ }
701
+
702
+ function expire() {
703
+ if (shuttingDown) return;
704
+ const current = now();
705
+ for (const pairing of pairings.values()) {
706
+ if (pairing.expiresAt <= current) pairings.delete(pairing.code);
707
+ }
708
+ for (const page of [...pageSessions.values()]) {
709
+ if (page.expiresAt <= current) {
710
+ retirePage(page, "PAGE_SESSION_EXPIRED", "Page session expired.", "expired");
711
+ }
712
+ }
713
+ for (const agent of [...agentSessions.values()]) {
714
+ if (!agent.revokedAt && agent.expiresAt <= current) expireAgent(agent, current);
715
+ if (agent.revokedAt && agent.revokedAt + revokedSessionRetentionMs < current) {
716
+ agentSessions.delete(agent.id);
717
+ }
718
+ }
719
+ for (const [id, entry] of requests) {
720
+ if (entry.response && entry.updatedAt + resultTtlMs < current) {
721
+ clearEntryTimer(entry);
722
+ requests.delete(id);
723
+ }
724
+ }
725
+ pruneWindows(demoWindows, maxDemoWindowMs, current);
726
+ pruneWindows(claimWindows, pairingClaimWindowMs, current);
727
+ }
728
+
729
+ function uniquePairingCode() {
730
+ for (let attempt = 0; attempt < 16; attempt += 1) {
731
+ const code = randomBytes(6).toString("hex").toUpperCase();
732
+ if (!pairings.has(code)) return code;
733
+ }
734
+ throw new GatewayError("PAIRING_CODE_UNAVAILABLE", "Could not allocate a unique pairing code.", 503);
735
+ }
736
+
737
+ function shutdown() {
738
+ if (shuttingDown) return;
739
+ shuttingDown = true;
740
+ const current = now();
741
+ for (const entry of requests.values()) {
742
+ clearEntryTimer(entry);
743
+ if (!entry.response) {
744
+ entry.response = failure(entry.id, "GATEWAY_SHUTDOWN", "Surface Gateway is shutting down.");
745
+ entry.updatedAt = current;
746
+ }
747
+ resolveFirst(entry);
748
+ }
749
+ for (const page of pageSessions.values()) {
750
+ if (page.socket) page.socket.close(1001, "gateway shutting down");
751
+ }
752
+ pageSessions.clear();
753
+ pageByToken.clear();
754
+ agentSessions.clear();
755
+ agentByToken.clear();
756
+ pairings.clear();
757
+ demoWindows.clear();
758
+ claimWindows.clear();
759
+ audit("gateway.shutdown");
760
+ }
761
+
762
+ function assertRunning() {
763
+ if (shuttingDown) {
764
+ throw new GatewayError("GATEWAY_CLOSED", "Surface Gateway has shut down.", 503);
765
+ }
766
+ }
767
+
768
+ return {
769
+ version: PACKAGE_VERSION,
770
+ config,
771
+ createPageSession,
772
+ createPublicDemoPageSession,
773
+ authenticatePage,
774
+ attachPage,
775
+ disconnectPage,
776
+ createPairing,
777
+ claimPairing,
778
+ authenticateAgent,
779
+ revokeAgentSession,
780
+ revokePageSession,
781
+ describe,
782
+ invoke,
783
+ status,
784
+ notePageMessage,
785
+ discover,
786
+ listSessions,
787
+ expire,
788
+ shutdown,
789
+ };
790
+ }
791
+
792
+ function requiredString(value, field, maxLength = MAX_ID_LENGTH) {
793
+ if (typeof value !== "string" || !value.trim()) {
794
+ throw new GatewayError("INVALID_REQUEST", `${field} must be a non-empty string.`, 400);
795
+ }
796
+ const text = value.trim();
797
+ if (text.length > maxLength) {
798
+ throw new GatewayError("INVALID_REQUEST", `${field} is too long.`, 400);
799
+ }
800
+ return text;
801
+ }
802
+
803
+ function optionalString(value, maxLength) {
804
+ if (typeof value !== "string") return null;
805
+ const text = value.trim();
806
+ return text && text.length <= maxLength ? text : null;
807
+ }
808
+
809
+ function normalizeScope(value) {
810
+ if (value === undefined) return [];
811
+ if (!Array.isArray(value)) {
812
+ throw new GatewayError("INVALID_SCOPE", "scope must be an array of strings.", 400);
813
+ }
814
+ if (value.length > MAX_SCOPE_ENTRIES) {
815
+ throw new GatewayError("INVALID_SCOPE", `scope must contain at most ${MAX_SCOPE_ENTRIES} entries.`, 400);
816
+ }
817
+ const seen = new Set();
818
+ for (const item of value) {
819
+ if (typeof item !== "string" || !item.trim() || item.trim().length > MAX_ID_LENGTH) {
820
+ throw new GatewayError("INVALID_SCOPE", "scope entries must be non-empty strings of bounded length.", 400);
821
+ }
822
+ seen.add(item.trim());
823
+ }
824
+ return [...seen];
825
+ }
826
+
827
+ function scopeSubset(requested, allowed) {
828
+ if (allowed.includes("*")) return true;
829
+ return requested.every(
830
+ (item) => item !== "*" && allowed.some((entry) => scopeEntryAllows(entry, item)),
831
+ );
832
+ }
833
+
834
+ function scopeAllowsCapability(allowed, capability) {
835
+ if (allowed.includes("*")) return true;
836
+ return allowed.some((entry) => scopeEntryAllows(entry, capability));
837
+ }
838
+
839
+ /**
840
+ * Match the prefix semantics used by @plingjs/surface:
841
+ * - an exact id also covers its `:` and `@` families;
842
+ * - an id ending in `:` or `@` is already a family prefix;
843
+ * - `*` is only a Gateway-level full-scope marker.
844
+ */
845
+ function scopeEntryAllows(allowed, requested) {
846
+ if (allowed === "*") return true;
847
+ if (requested === "*") return false;
848
+ if (allowed === requested) return true;
849
+ if (allowed.endsWith(":") || allowed.endsWith("@")) {
850
+ return requested.startsWith(allowed) && requested.length > allowed.length;
851
+ }
852
+ return requested.startsWith(`${allowed}:`) || requested.startsWith(`${allowed}@`);
853
+ }
854
+
855
+ function normalizeApproval(value, now) {
856
+ const approval = value && typeof value === "object" && !Array.isArray(value) ? value : {};
857
+ return {
858
+ description: typeof approval.description === "string" ? approval.description : "Operation requires confirmation",
859
+ args: approval.args && typeof approval.args === "object" && !Array.isArray(approval.args) ? approval.args : {},
860
+ kind: typeof approval.kind === "string" ? approval.kind : "write",
861
+ expiresAt: Number.isFinite(approval.expiresAt) ? approval.expiresAt : now() + 120_000,
862
+ ...(approval.flow && typeof approval.flow === "object" ? { flow: approval.flow } : {}),
863
+ };
864
+ }
865
+
866
+ function failure(id, code, message) {
867
+ return { protocol: SURFACE_PROTOCOL, id, ok: false, error: { code, message } };
868
+ }
869
+
870
+ function isSocketOpen(socket) {
871
+ return Boolean(socket) && socket.readyState === OPEN;
872
+ }
873
+
874
+ function clearEntryTimer(entry) {
875
+ if (!entry.timer) return;
876
+ clearTimeout(entry.timer);
877
+ entry.timer = null;
878
+ }
879
+
880
+ function activeWindow(value, windowMs, current) {
881
+ return (value ?? []).filter((at) => at + windowMs > current);
882
+ }
883
+
884
+ function pruneWindows(map, windowMs, current) {
885
+ if (windowMs <= 0) {
886
+ map.clear();
887
+ return;
888
+ }
889
+ for (const [key, values] of map) {
890
+ const active = activeWindow(values, windowMs, current);
891
+ if (active.length === 0) map.delete(key);
892
+ else if (active.length !== values.length) map.set(key, active);
893
+ }
894
+ }
895
+
896
+ function clampTtl(value, maximum, fallback) {
897
+ const requested = Number.isInteger(value) && value > 0 ? value : fallback;
898
+ return Math.max(1, Math.min(requested, maximum));
899
+ }
900
+
901
+ function positiveOption(value, fallback, name) {
902
+ if (value === undefined || value === null) return fallback;
903
+ if (!Number.isInteger(value) || value <= 0) {
904
+ throw new TypeError(`${name} must be a positive integer.`);
905
+ }
906
+ return value;
907
+ }
908
+
909
+ function normalizePublicBaseUrl(value) {
910
+ if (typeof value !== "string" || !value.trim()) {
911
+ throw new TypeError("publicBaseUrl is required.");
912
+ }
913
+ let url;
914
+ try {
915
+ url = new URL(value);
916
+ } catch {
917
+ throw new TypeError("publicBaseUrl must be an absolute http(s) URL.");
918
+ }
919
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
920
+ throw new TypeError("publicBaseUrl must use http or https.");
921
+ }
922
+ if (url.username || url.password || url.search || url.hash) {
923
+ throw new TypeError("publicBaseUrl must not contain credentials, a query, or a fragment.");
924
+ }
925
+ return url.toString().replace(/\/$/, "");
926
+ }
927
+
928
+ function websocketUrl(base) {
929
+ const url = new URL(base);
930
+ url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
931
+ url.pathname = `${url.pathname.replace(/\/$/, "")}/surface`;
932
+ url.search = "";
933
+ url.hash = "";
934
+ return url.toString();
935
+ }