@ggui-ai/mcp-server 0.2.0-alpha.3 → 0.3.0-rc.0

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.
Files changed (152) hide show
  1. package/dist/admin-blueprints-transport.d.ts.map +1 -1
  2. package/dist/admin-blueprints-transport.js +2 -1
  3. package/dist/admin-oauth-providers-transport.d.ts.map +1 -1
  4. package/dist/admin-oauth-providers-transport.js +7 -5
  5. package/dist/api-renders-routes.d.ts +85 -0
  6. package/dist/api-renders-routes.d.ts.map +1 -0
  7. package/dist/api-renders-routes.js +372 -0
  8. package/dist/build-mcp.d.ts +1 -1
  9. package/dist/build-mcp.d.ts.map +1 -1
  10. package/dist/build-mcp.js +39 -5
  11. package/dist/code-routes.d.ts +47 -0
  12. package/dist/code-routes.d.ts.map +1 -0
  13. package/dist/code-routes.js +81 -0
  14. package/dist/code-store-fs.js +2 -2
  15. package/dist/console-auth.d.ts +10 -10
  16. package/dist/console-auth.d.ts.map +1 -1
  17. package/dist/console-auth.js +5 -5
  18. package/dist/console-blueprint-routes.d.ts +71 -0
  19. package/dist/console-blueprint-routes.d.ts.map +1 -0
  20. package/dist/console-blueprint-routes.js +348 -0
  21. package/dist/console-chat-routes.d.ts +80 -0
  22. package/dist/console-chat-routes.d.ts.map +1 -0
  23. package/dist/console-chat-routes.js +182 -0
  24. package/dist/console-config-routes.d.ts +37 -0
  25. package/dist/console-config-routes.d.ts.map +1 -0
  26. package/dist/console-config-routes.js +91 -0
  27. package/dist/console-headers.d.ts +1 -1
  28. package/dist/console-info-routes.d.ts +84 -0
  29. package/dist/console-info-routes.d.ts.map +1 -0
  30. package/dist/console-info-routes.js +135 -0
  31. package/dist/console-keys-routes.d.ts +50 -0
  32. package/dist/console-keys-routes.d.ts.map +1 -0
  33. package/dist/console-keys-routes.js +222 -0
  34. package/dist/console-llm-keys-routes.d.ts +47 -0
  35. package/dist/console-llm-keys-routes.d.ts.map +1 -0
  36. package/dist/console-llm-keys-routes.js +443 -0
  37. package/dist/console-mcp-tools-routes.d.ts +41 -0
  38. package/dist/console-mcp-tools-routes.d.ts.map +1 -0
  39. package/dist/console-mcp-tools-routes.js +60 -0
  40. package/dist/console-registry-routes.d.ts +66 -0
  41. package/dist/console-registry-routes.d.ts.map +1 -0
  42. package/dist/console-registry-routes.js +276 -0
  43. package/dist/console-session-routes.d.ts +89 -0
  44. package/dist/console-session-routes.d.ts.map +1 -0
  45. package/dist/console-session-routes.js +385 -0
  46. package/dist/console-sessions-routes.d.ts +52 -0
  47. package/dist/console-sessions-routes.d.ts.map +1 -0
  48. package/dist/console-sessions-routes.js +106 -0
  49. package/dist/console-static-routes.d.ts +54 -0
  50. package/dist/console-static-routes.d.ts.map +1 -0
  51. package/dist/console-static-routes.js +190 -0
  52. package/dist/console-theme-routes.d.ts +3 -3
  53. package/dist/console-theme-routes.js +1 -1
  54. package/dist/console-timeline.d.ts +5 -5
  55. package/dist/console-timeline.d.ts.map +1 -1
  56. package/dist/console-timeline.js +27 -26
  57. package/dist/console-welcome.js +2 -2
  58. package/dist/email-login.d.ts.map +1 -1
  59. package/dist/email-login.js +2 -3
  60. package/dist/ggui-session-channel/action-ingress.d.ts +54 -0
  61. package/dist/ggui-session-channel/action-ingress.d.ts.map +1 -0
  62. package/dist/ggui-session-channel/action-ingress.js +228 -0
  63. package/dist/ggui-session-channel/channel-subscriptions.d.ts +97 -0
  64. package/dist/ggui-session-channel/channel-subscriptions.d.ts.map +1 -0
  65. package/dist/ggui-session-channel/channel-subscriptions.js +224 -0
  66. package/dist/ggui-session-channel/internal-types.d.ts +102 -0
  67. package/dist/ggui-session-channel/internal-types.d.ts.map +1 -0
  68. package/dist/ggui-session-channel/internal-types.js +6 -0
  69. package/dist/ggui-session-channel/outbound.d.ts +81 -0
  70. package/dist/ggui-session-channel/outbound.d.ts.map +1 -0
  71. package/dist/ggui-session-channel/outbound.js +174 -0
  72. package/dist/ggui-session-channel/socket-router.d.ts +38 -0
  73. package/dist/ggui-session-channel/socket-router.d.ts.map +1 -0
  74. package/dist/ggui-session-channel/socket-router.js +213 -0
  75. package/dist/ggui-session-channel/subscribe.d.ts +165 -0
  76. package/dist/ggui-session-channel/subscribe.d.ts.map +1 -0
  77. package/dist/ggui-session-channel/subscribe.js +370 -0
  78. package/dist/ggui-session-channel/subscriber-lifecycle.d.ts +40 -0
  79. package/dist/ggui-session-channel/subscriber-lifecycle.d.ts.map +1 -0
  80. package/dist/ggui-session-channel/subscriber-lifecycle.js +123 -0
  81. package/dist/ggui-session-channel.d.ts +425 -0
  82. package/dist/ggui-session-channel.d.ts.map +1 -0
  83. package/dist/ggui-session-channel.js +262 -0
  84. package/dist/health-routes.d.ts +76 -0
  85. package/dist/health-routes.d.ts.map +1 -0
  86. package/dist/health-routes.js +145 -0
  87. package/dist/index.d.ts +10 -11
  88. package/dist/index.d.ts.map +1 -1
  89. package/dist/index.js +8 -9
  90. package/dist/instructions-presets.d.ts +3 -3
  91. package/dist/instructions-presets.js +25 -25
  92. package/dist/llm-backed-negotiator.d.ts +68 -67
  93. package/dist/llm-backed-negotiator.d.ts.map +1 -1
  94. package/dist/llm-backed-negotiator.js +82 -248
  95. package/dist/mcp-apps-outbound.d.ts +47 -48
  96. package/dist/mcp-apps-outbound.d.ts.map +1 -1
  97. package/dist/mcp-apps-outbound.js +204 -183
  98. package/dist/mcp-endpoint-routes.d.ts +88 -0
  99. package/dist/mcp-endpoint-routes.d.ts.map +1 -0
  100. package/dist/mcp-endpoint-routes.js +359 -0
  101. package/dist/mcp-mounts.d.ts +2 -76
  102. package/dist/mcp-mounts.d.ts.map +1 -1
  103. package/dist/mcp-mounts.js +0 -76
  104. package/dist/oauth-as-routes.d.ts +60 -0
  105. package/dist/oauth-as-routes.d.ts.map +1 -0
  106. package/dist/oauth-as-routes.js +82 -0
  107. package/dist/oauth-clients-routes.d.ts +39 -0
  108. package/dist/oauth-clients-routes.d.ts.map +1 -0
  109. package/dist/oauth-clients-routes.js +87 -0
  110. package/dist/oauth-login-types.d.ts +1 -20
  111. package/dist/oauth-login-types.d.ts.map +1 -1
  112. package/dist/oauth-login-types.js +30 -7
  113. package/dist/oauth-login.d.ts.map +1 -1
  114. package/dist/oauth-login.js +3 -2
  115. package/dist/oauth-providers-store.d.ts.map +1 -1
  116. package/dist/oauth-providers-store.js +5 -5
  117. package/dist/oauth.d.ts +9 -8
  118. package/dist/oauth.d.ts.map +1 -1
  119. package/dist/oauth.js +41 -19
  120. package/dist/pairing-transport.d.ts.map +1 -1
  121. package/dist/pairing-transport.js +2 -1
  122. package/dist/request-context.d.ts +2 -2
  123. package/dist/request-context.js +2 -2
  124. package/dist/reserved-validators.d.ts.map +1 -1
  125. package/dist/reserved-validators.js +9 -1
  126. package/dist/route-param.d.ts +9 -0
  127. package/dist/route-param.d.ts.map +1 -0
  128. package/dist/route-param.js +10 -0
  129. package/dist/runtime-bundle-route.d.ts +43 -0
  130. package/dist/runtime-bundle-route.d.ts.map +1 -0
  131. package/dist/runtime-bundle-route.js +80 -0
  132. package/dist/schema-compat.d.ts +64 -62
  133. package/dist/schema-compat.d.ts.map +1 -1
  134. package/dist/schema-compat.js +23 -51
  135. package/dist/server.d.ts +179 -193
  136. package/dist/server.d.ts.map +1 -1
  137. package/dist/server.js +644 -3759
  138. package/dist/storage.d.ts +5 -5
  139. package/dist/storage.d.ts.map +1 -1
  140. package/dist/storage.js +5 -5
  141. package/dist/thread-transport.d.ts.map +1 -1
  142. package/dist/thread-transport.js +4 -3
  143. package/dist/user-session-auth.d.ts +7 -21
  144. package/dist/user-session-auth.d.ts.map +1 -1
  145. package/dist/user-session-auth.js +7 -28
  146. package/package.json +16 -15
  147. package/dist/mcp-apps-inbound.d.ts +0 -86
  148. package/dist/mcp-apps-inbound.d.ts.map +0 -1
  149. package/dist/mcp-apps-inbound.js +0 -283
  150. package/dist/render-channel.d.ts +0 -694
  151. package/dist/render-channel.d.ts.map +0 -1
  152. package/dist/render-channel.js +0 -1775
@@ -0,0 +1,228 @@
1
+ /**
2
+ * Inbound `action` ingress + consume bridge for the live channel —
3
+ * contract enforcement on user gestures arriving over WS, the
4
+ * dual-write onto the retained event ledger + the pending-events pipe
5
+ * (`ggui_consume`'s queue), and the ack carrying the ledger seq back
6
+ * to the client.
7
+ */
8
+ import { assertActionContract } from "@ggui-ai/mcp-server-handlers/renders";
9
+ import { ContractViolationError } from "@ggui-ai/protocol";
10
+ import { randomBytes } from "node:crypto";
11
+ /**
12
+ * Resolve the active render variant for contract enforcement. Phase
13
+ * B collapsed the prior (stack, currentStackIndex) lookup — a render
14
+ * IS the addressable unit, so the active render is the stored render
15
+ * itself. MCP Apps / system variants narrow to `undefined` so
16
+ * upstream enforcement skips (allowlist + actionSpec checks are
17
+ * no-ops when no `ComponentGguiSession` is active).
18
+ */
19
+ function resolveActiveGguiSession(render) {
20
+ if (!render)
21
+ return undefined;
22
+ if (render.type === "mcpApps" || render.type === "system")
23
+ return undefined;
24
+ return render;
25
+ }
26
+ /**
27
+ * Stamp the `tool` hint onto a `data:submit` envelope's
28
+ * `ActionEventValue` payload before it persists onto the retained
29
+ * event ledger (`user.submitted`).
30
+ *
31
+ * The hint derives server-side from the active render's
32
+ * `actionSpec[action].nextStep` — the single authoritative source —
33
+ * and only fills the gap when the inbound payload carries no `tool`
34
+ * of its own. It rides the LEDGER copy only (operator surfaces —
35
+ * console timeline, inspector feeds — read it); the consume-pipe
36
+ * entry is the relay-identical {@link ConsumeEventEntry}, which
37
+ * carries no tool slot — the agent reads `nextStep` from the
38
+ * contract it authored.
39
+ *
40
+ * Pass-through (returns the envelope unchanged) when:
41
+ * - the envelope is not `data:submit`,
42
+ * - no `ComponentGguiSession` is active (mcpApps / system),
43
+ * - the payload lacks a string `action` or already carries a
44
+ * non-empty `tool`,
45
+ * - the named action declares no `nextStep`.
46
+ */
47
+ function withDerivedToolHint(envelope, activeItem) {
48
+ if (envelope.type !== "data:submit" || !activeItem)
49
+ return envelope;
50
+ if (activeItem.type === "mcpApps" || activeItem.type === "system")
51
+ return envelope;
52
+ const payload = envelope.payload;
53
+ if (payload === null ||
54
+ payload === undefined ||
55
+ typeof payload !== "object" ||
56
+ Array.isArray(payload)) {
57
+ return envelope;
58
+ }
59
+ if (typeof payload.action !== "string" || payload.action.length === 0)
60
+ return envelope;
61
+ if (typeof payload.tool === "string" && payload.tool.length > 0)
62
+ return envelope;
63
+ const nextStep = activeItem.actionSpec?.[payload.action]?.nextStep;
64
+ if (typeof nextStep !== "string" || nextStep.length === 0)
65
+ return envelope;
66
+ return { ...envelope, payload: { ...payload, tool: nextStep } };
67
+ }
68
+ /**
69
+ * Project an accepted `data:submit` {@link ActionEnvelope} onto the
70
+ * canonical {@link ConsumeEventEntry} shape the pending-events pipe
71
+ * stores — the SAME shape `ggui_runtime_submit_action`'s dispatch
72
+ * branch appends, so `ggui_consume` drains WS-originated gestures
73
+ * and tools/call-relayed gestures identically.
74
+ *
75
+ * Field mapping:
76
+ * - `intent` ← `payload.action` (the actionSpec key).
77
+ * - `actionData` ← `payload.data ?? null` (already validated by
78
+ * {@link assertActionContract} when a spec is declared).
79
+ * - `uiContext` ← `{}` — WS clients don't mirror a contextSpec
80
+ * snapshot (that's the iframe-runtime observer's job); the empty
81
+ * object is the type's canonical "no slots mirrored" value.
82
+ * - `actionId` ← server-minted 8-hex correlation id. The WS wire
83
+ * envelope carries none (only the iframe-runtime computes a
84
+ * gesture-side FNV-1a hash); minting here keeps the pipe entry's
85
+ * `drain_ack` keying well-formed.
86
+ * - `firedAt` ← server clock — the WS envelope deliberately
87
+ * carries no client timestamp (see {@link ActionEnvelope}).
88
+ *
89
+ * Returns `null` when the payload lacks a non-empty string `action`
90
+ * (possible only on spec-less renders, where the contract gate is
91
+ * permissive) — there is no intent to key the entry on, so the
92
+ * gesture stays ledger-only.
93
+ */
94
+ function toConsumeEventEntry(envelope, sessionId) {
95
+ const payload = envelope.payload;
96
+ if (payload === null ||
97
+ payload === undefined ||
98
+ typeof payload !== "object" ||
99
+ Array.isArray(payload)) {
100
+ return null;
101
+ }
102
+ const action = payload.action;
103
+ if (typeof action !== "string" || action.length === 0)
104
+ return null;
105
+ return {
106
+ type: "action",
107
+ sessionId,
108
+ intent: action,
109
+ actionData: payload.data ?? null,
110
+ uiContext: {},
111
+ actionId: randomBytes(4).toString("hex"),
112
+ firedAt: new Date().toISOString(),
113
+ };
114
+ }
115
+ export function createActionIngress(deps) {
116
+ async function handleInboundAction(ws, sub, message) {
117
+ const envelope = message.payload;
118
+ // Spoof guard — envelope.sessionId is REQUIRED on the wire and
119
+ // MUST match the subscriber's bound render.
120
+ if (envelope.sessionId !== sub.sessionId) {
121
+ deps.sendError(ws, "SESSION_MISMATCH", `Action targets render '${envelope.sessionId}' but this socket is subscribed to '${sub.sessionId}'`, message.requestId);
122
+ return;
123
+ }
124
+ const stored = await deps.renderStore.get(sub.sessionId);
125
+ if (!stored) {
126
+ deps.sendError(ws, "SESSION_NOT_FOUND", `GguiSession ${sub.sessionId} no longer exists`, message.requestId);
127
+ return;
128
+ }
129
+ // Phase B: a render IS the addressable unit. The prior stack
130
+ // routing (stackIndex / cross-stack pickIds) collapses — the
131
+ // resolved render itself is the active item.
132
+ const activeItem = resolveActiveGguiSession(stored.render);
133
+ // Contract enforcement: actionSpec payload check via
134
+ // assertActionContract (data:submit only). Envelope.payload for
135
+ // data:submit carries the ActionEventValue shape
136
+ // (`{action, data?, tool?}`).
137
+ if (envelope.type === "data:submit") {
138
+ try {
139
+ const activeActionSpec = activeItem && activeItem.type !== "mcpApps" && activeItem.type !== "system"
140
+ ? activeItem.actionSpec
141
+ : undefined;
142
+ assertActionContract(activeActionSpec, envelope.payload);
143
+ }
144
+ catch (err) {
145
+ if (err instanceof ContractViolationError) {
146
+ deps.logger.warn("render_channel_contract_violation", {
147
+ sessionId: sub.sessionId,
148
+ violations: err.violations,
149
+ envelope: "action",
150
+ });
151
+ deps.sendError(ws, "CONTRACT_VIOLATION", err.message, message.requestId, err.toErrorData());
152
+ return;
153
+ }
154
+ throw err;
155
+ }
156
+ }
157
+ // Dual-write, mirroring `ggui_runtime_submit_action`'s dispatch
158
+ // branch (`createGguiSubmitActionHandler`):
159
+ //
160
+ // 1. Ledger — `GguiSessionStore.appendEvent` assigns a monotonic
161
+ // seq the client acks back with so reconnects can resume via
162
+ // `fromSeq`. This retained copy is also the single build site
163
+ // for the operator-facing `tool` hint — see
164
+ // {@link withDerivedToolHint}.
165
+ // 2. Pipe — for `data:submit` envelopes, the consume-entry
166
+ // projection ({@link toConsumeEventEntry}) lands on the
167
+ // pending-events pipe so the agent's `ggui_consume` long-poll
168
+ // drains it mid-turn. The ledger and the pipe are two
169
+ // different streams (queue vs append-only retained — see
170
+ // `pending-event-consumer.ts`); without this write a WS
171
+ // gesture would never reach the agent.
172
+ //
173
+ // Both writes fire concurrently via `Promise.allSettled` so each
174
+ // outcome is inspected independently: a ledger rejection is the
175
+ // load-bearing `APPEND_FAILED` error path (unchanged ack
176
+ // semantics); a pipe rejection (pipe never opened / already
177
+ // reaped) degrades to ledger-only with a warn — the WS client has
178
+ // no `ui/message` fallback to branch on, so a new error frame
179
+ // would be vocabulary without a consumer.
180
+ const consumeWrite = (() => {
181
+ if (deps.pendingEventConsumer === undefined || envelope.type !== "data:submit") {
182
+ return Promise.resolve();
183
+ }
184
+ const entry = toConsumeEventEntry(envelope, sub.sessionId);
185
+ if (entry === null)
186
+ return Promise.resolve();
187
+ return deps.pendingEventConsumer.append(sub.sessionId, {
188
+ // The pipe entry's stable id doubles as the `drain_ack` key —
189
+ // same convention as the relay path's iframe-supplied id.
190
+ id: entry.actionId,
191
+ envelope: entry,
192
+ createdAt: entry.firedAt,
193
+ });
194
+ })();
195
+ const [ledgerResult, pipeResult] = await Promise.allSettled([
196
+ deps.renderStore.appendEvent({
197
+ sessionId: sub.sessionId,
198
+ type: "user.submitted",
199
+ data: withDerivedToolHint(envelope, activeItem),
200
+ }),
201
+ consumeWrite,
202
+ ]);
203
+ if (pipeResult.status === "rejected") {
204
+ deps.logger.warn("render_channel_consume_append_failed", {
205
+ sessionId: sub.sessionId,
206
+ error: pipeResult.reason instanceof Error
207
+ ? pipeResult.reason.message
208
+ : String(pipeResult.reason),
209
+ });
210
+ }
211
+ if (ledgerResult.status === "rejected") {
212
+ const err = ledgerResult.reason;
213
+ deps.logger.error("render_channel_append_failed", {
214
+ sessionId: sub.sessionId,
215
+ error: String(err),
216
+ });
217
+ deps.sendError(ws, "APPEND_FAILED", err instanceof Error ? err.message : String(err), message.requestId);
218
+ return;
219
+ }
220
+ const seq = ledgerResult.value;
221
+ deps.send(ws, {
222
+ type: "ack",
223
+ payload: { sequence: seq, timestamp: Date.now() },
224
+ ...(message.requestId ? { requestId: message.requestId } : {}),
225
+ });
226
+ }
227
+ return { handleInboundAction };
228
+ }
@@ -0,0 +1,97 @@
1
+ /**
2
+ * `channel_subscribe` / source-poll family for the live channel —
3
+ * server-side polling of `streamSpec[ch].source.tool` for the subset
4
+ * of tools the operator listed on `streamWebSocketLocalTools`, fanned
5
+ * to the subscriber as `channel_payload` frames. Includes the
6
+ * symmetric `channel_unsubscribe` handler; WS close tears down any
7
+ * remaining polling loops via the subscriber-lifecycle module.
8
+ */
9
+ import type { GguiSessionStore } from "@ggui-ai/mcp-server-core";
10
+ import type { WebSocketMessage } from "@ggui-ai/protocol/transport/websocket";
11
+ import type { WebSocket } from "ws";
12
+ import type { Logger } from "../logger.js";
13
+ import type { Subscriber } from "./internal-types.js";
14
+ import type { Outbound } from "./outbound.js";
15
+ /**
16
+ * Opt-in plumbing for the `channel_subscribe` polling loop. When this
17
+ * field is set on `GguiSessionChannelOptions`, channel subscribes
18
+ * whose `source.tool` is in {@link allowlist} are accepted and the
19
+ * server begins polling. When absent, every `channel_subscribe`
20
+ * returns `CHANNEL_NOT_LOCAL` so the iframe falls back to direct
21
+ * polling via the MCP host proxy.
22
+ */
23
+ export interface GguiSessionChannelLocalToolsOptions {
24
+ /**
25
+ * Whitelist of `source.tool` names this channel can poll. Must mirror
26
+ * the value the host advertises on
27
+ * `handshake.serverCapabilities.streamWebSocketLocalTools` so the
28
+ * iframe + server agree on which channels use the WS fan-out path.
29
+ * Tools NOT in this list are rejected with `CHANNEL_NOT_LOCAL` (the
30
+ * iframe falls back to direct polling).
31
+ */
32
+ readonly allowlist: readonly string[];
33
+ /**
34
+ * Synchronous resolver invoked at poll time. Returns the tool's
35
+ * structured output (validated against `streamSpec[ch].schema`
36
+ * client-side; server-side schema validation is deferred to the
37
+ * future `validateContract` slice). Implementations typically
38
+ * delegate to the same in-process tool registry that backs `/mcp`.
39
+ *
40
+ * Throwing surfaces `POLL_FAILED` on the subscriber's
41
+ * `channel_error` channel without canceling the poll loop —
42
+ * transient tool failures are recoverable.
43
+ */
44
+ invoke(name: string, input: unknown): Promise<unknown>;
45
+ /**
46
+ * Optional poll cadence policy. `defaultMs` applies when the client
47
+ * doesn't supply a `pollIntervalMs`; `floorMs`/`ceilingMs` clamp
48
+ * client-supplied values. Defaults:
49
+ * `{floorMs: 1000, ceilingMs: 60000, defaultMs: 10000}`.
50
+ */
51
+ readonly pollCadence?: {
52
+ readonly floorMs?: number;
53
+ readonly ceilingMs?: number;
54
+ readonly defaultMs?: number;
55
+ };
56
+ }
57
+ export interface ChannelSubscriptionsDeps {
58
+ readonly logger: Logger;
59
+ readonly renderStore: GguiSessionStore;
60
+ /**
61
+ * Channel-subscribe local-tool poll plumbing, straight from
62
+ * `GguiSessionChannelOptions.streamWebSocketLocalTools`. Absent ⇒
63
+ * all channel subscribes reject with `CHANNEL_NOT_LOCAL`.
64
+ */
65
+ readonly localTools: GguiSessionChannelLocalToolsOptions | undefined;
66
+ /**
67
+ * ws → subscriber reverse index — the zombie-timer guard reads it at
68
+ * callback fire-time to self-clean intervals that outlived their
69
+ * subscriber.
70
+ */
71
+ readonly subscribersByWs: WeakMap<WebSocket, Subscriber>;
72
+ readonly send: Outbound["send"];
73
+ readonly sendChannelError: Outbound["sendChannelError"];
74
+ }
75
+ export interface ChannelSubscriptions {
76
+ /**
77
+ * Handle a `channel_subscribe` message. Validates the request,
78
+ * resolves the channel's `source.tool` against the configured
79
+ * allowlist, and schedules a polling loop. Idempotent on
80
+ * `${sessionId}:${channelName}` — a re-subscribe replaces any
81
+ * existing interval rather than running two in parallel.
82
+ */
83
+ handleChannelSubscribe(ws: WebSocket, sub: Subscriber, message: WebSocketMessage & {
84
+ type: "channel_subscribe";
85
+ }): Promise<void>;
86
+ /**
87
+ * Handle a `channel_unsubscribe` message. Idempotent: a no-op
88
+ * unsubscribe on an unknown channelKey returns silently. WS close
89
+ * implicitly unsubscribes every channel; this message is for
90
+ * mid-session cancellation.
91
+ */
92
+ handleChannelUnsubscribe(ws: WebSocket, sub: Subscriber, message: WebSocketMessage & {
93
+ type: "channel_unsubscribe";
94
+ }): void;
95
+ }
96
+ export declare function createChannelSubscriptions(deps: ChannelSubscriptionsDeps): ChannelSubscriptions;
97
+ //# sourceMappingURL=channel-subscriptions.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"channel-subscriptions.d.ts","sourceRoot":"","sources":["../../src/ggui-session-channel/channel-subscriptions.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,0BAA0B,CAAC;AAGjE,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,uCAAuC,CAAC;AAC9E,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,IAAI,CAAC;AACpC,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,cAAc,CAAC;AAC3C,OAAO,KAAK,EAA4B,UAAU,EAAE,MAAM,qBAAqB,CAAC;AAChF,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,eAAe,CAAC;AAa9C;;;;;;;GAOG;AACH,MAAM,WAAW,mCAAmC;IAClD;;;;;;;OAOG;IACH,QAAQ,CAAC,SAAS,EAAE,SAAS,MAAM,EAAE,CAAC;IACtC;;;;;;;;;;OAUG;IACH,MAAM,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;IACvD;;;;;OAKG;IACH,QAAQ,CAAC,WAAW,CAAC,EAAE;QACrB,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,CAAC;QAC1B,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;QAC5B,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;KAC7B,CAAC;CACH;AAED,MAAM,WAAW,wBAAwB;IACvC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,WAAW,EAAE,gBAAgB,CAAC;IACvC;;;;OAIG;IACH,QAAQ,CAAC,UAAU,EAAE,mCAAmC,GAAG,SAAS,CAAC;IACrE;;;;OAIG;IACH,QAAQ,CAAC,eAAe,EAAE,OAAO,CAAC,SAAS,EAAE,UAAU,CAAC,CAAC;IACzD,QAAQ,CAAC,IAAI,EAAE,QAAQ,CAAC,MAAM,CAAC,CAAC;IAChC,QAAQ,CAAC,gBAAgB,EAAE,QAAQ,CAAC,kBAAkB,CAAC,CAAC;CACzD;AAED,MAAM,WAAW,oBAAoB;IACnC;;;;;;OAMG;IACH,sBAAsB,CACpB,EAAE,EAAE,SAAS,EACb,GAAG,EAAE,UAAU,EACf,OAAO,EAAE,gBAAgB,GAAG;QAAE,IAAI,EAAE,mBAAmB,CAAA;KAAE,GACxD,OAAO,CAAC,IAAI,CAAC,CAAC;IACjB;;;;;OAKG;IACH,wBAAwB,CACtB,EAAE,EAAE,SAAS,EACb,GAAG,EAAE,UAAU,EACf,OAAO,EAAE,gBAAgB,GAAG;QAAE,IAAI,EAAE,qBAAqB,CAAA;KAAE,GAC1D,IAAI,CAAC;CACT;AAED,wBAAgB,0BAA0B,CAAC,IAAI,EAAE,wBAAwB,GAAG,oBAAoB,CAiQ/F"}
@@ -0,0 +1,224 @@
1
+ /**
2
+ * `channel_subscribe` / source-poll family for the live channel —
3
+ * server-side polling of `streamSpec[ch].source.tool` for the subset
4
+ * of tools the operator listed on `streamWebSocketLocalTools`, fanned
5
+ * to the subscriber as `channel_payload` frames. Includes the
6
+ * symmetric `channel_unsubscribe` handler; WS close tears down any
7
+ * remaining polling loops via the subscriber-lifecycle module.
8
+ */
9
+ import { sanitizeCausedBy } from "@ggui-ai/protocol";
10
+ /**
11
+ * Default + boundary cadence for the channel-subscribe polling loop.
12
+ * Server-authoritative: clients propose `pollIntervalMs` on
13
+ * `channel_subscribe`, server clamps to [floorMs, ceilingMs] and
14
+ * defaults to `defaultMs` when absent. Conservative defaults — operators
15
+ * tune via {@link GguiSessionChannelLocalToolsOptions.pollCadence}.
16
+ */
17
+ const DEFAULT_CHANNEL_POLL_FLOOR_MS = 1_000;
18
+ const DEFAULT_CHANNEL_POLL_CEILING_MS = 60_000;
19
+ const DEFAULT_CHANNEL_POLL_DEFAULT_MS = 10_000;
20
+ export function createChannelSubscriptions(deps) {
21
+ // Poll plumbing resolved once at composition so the
22
+ // `channel_subscribe` handler doesn't pay the option-spread cost per
23
+ // request.
24
+ const localTools = deps.localTools;
25
+ const localToolsAllowlist = localTools
26
+ ? new Set(localTools.allowlist)
27
+ : new Set();
28
+ const pollFloorMs = localTools?.pollCadence?.floorMs ?? DEFAULT_CHANNEL_POLL_FLOOR_MS;
29
+ const pollCeilingMs = localTools?.pollCadence?.ceilingMs ?? DEFAULT_CHANNEL_POLL_CEILING_MS;
30
+ const pollDefaultMs = localTools?.pollCadence?.defaultMs ?? DEFAULT_CHANNEL_POLL_DEFAULT_MS;
31
+ /**
32
+ * Clamp a client-supplied `pollIntervalMs` against the configured
33
+ * floor / ceiling. Absent (or non-finite) ⇒ default. Server is
34
+ * authoritative per the `ChannelSubscribePayload.pollIntervalMs`
35
+ * docstring — clients propose, server clamps.
36
+ */
37
+ function clampPollInterval(supplied) {
38
+ if (typeof supplied !== "number" || !Number.isFinite(supplied)) {
39
+ return pollDefaultMs;
40
+ }
41
+ if (supplied < pollFloorMs)
42
+ return pollFloorMs;
43
+ if (supplied > pollCeilingMs)
44
+ return pollCeilingMs;
45
+ return supplied;
46
+ }
47
+ /**
48
+ * Run one poll of `state.toolName` and fan its result onto the
49
+ * subscriber's WS as a `channel_payload`. Throws never escape — a
50
+ * thrown invocation surfaces as a `channel_error{code:'POLL_FAILED'}`
51
+ * but the polling loop keeps running so transient tool failures are
52
+ * recoverable.
53
+ *
54
+ * Tool registry is guaranteed-present by the caller — channel
55
+ * subscribes whose `source.tool` isn't in `localTools.allowlist`
56
+ * never reach this function.
57
+ */
58
+ async function pollChannelOnce(sub, state) {
59
+ if (!localTools)
60
+ return;
61
+ if (sub.ws.readyState !== sub.ws.OPEN)
62
+ return;
63
+ try {
64
+ const output = await localTools.invoke(state.toolName, state.args);
65
+ // Skip emission if the socket closed during the poll — closing-
66
+ // raced timers fire at most once, and emitting onto a closing
67
+ // socket is a `send_failed` warning at best.
68
+ if (sub.ws.readyState !== sub.ws.OPEN)
69
+ return;
70
+ state.seq += 1;
71
+ deps.send(sub.ws, {
72
+ type: "channel_payload",
73
+ payload: {
74
+ sessionId: sub.sessionId,
75
+ appId: sub.appId,
76
+ channelName: state.channelName,
77
+ seq: state.seq,
78
+ ts: new Date().toISOString(),
79
+ // Default mode for source-fed channels is `replace` — each
80
+ // poll is a fresh snapshot, not a delta. Channels that need
81
+ // append semantics declare `mode: 'append'` on streamSpec;
82
+ // honoring that is the iframe-runtime's concern at fold
83
+ // time. See `ChannelPayloadFrame.mode`.
84
+ mode: "replace",
85
+ payload: output,
86
+ },
87
+ });
88
+ }
89
+ catch (err) {
90
+ if (sub.ws.readyState !== sub.ws.OPEN)
91
+ return;
92
+ deps.sendChannelError(sub.ws, sub.sessionId, state.channelName, "POLL_FAILED", err instanceof Error ? err.message : String(err), undefined,
93
+ // causedBy slot — the raw stack is redacted (Bearer tokens,
94
+ // query-param secrets, env-var dumps, 2 KB truncation) before
95
+ // it reaches the wire; raw `err.stack` verbatim is a
96
+ // credential-leak footgun.
97
+ sanitizeCausedBy(err instanceof Error ? err.stack ?? err.message : String(err)));
98
+ deps.logger.warn("render_channel_channel_poll_failed", {
99
+ sessionId: sub.sessionId,
100
+ appId: sub.appId,
101
+ channelName: state.channelName,
102
+ toolName: state.toolName,
103
+ error: String(err),
104
+ });
105
+ }
106
+ }
107
+ async function handleChannelSubscribe(ws, sub, message) {
108
+ const payload = message.payload;
109
+ // sessionId match — the spoof guard at every wire-input boundary.
110
+ // A subscriber bound to render A can't drive a subscribe for
111
+ // render B even if they crafted the inbound payload.
112
+ if (payload.sessionId !== sub.sessionId) {
113
+ deps.sendChannelError(ws, payload.sessionId, payload.channelName, "SUBSCRIBE_UNAUTHORIZED", `Subscriber is bound to render '${sub.sessionId}' but channel_subscribe targets '${payload.sessionId}'`, message.requestId);
114
+ return;
115
+ }
116
+ // Without an `streamWebSocketLocalTools` allowlist, no channel
117
+ // can be subscribed locally. The iframe must fall back to direct
118
+ // polling via the MCP host proxy.
119
+ if (!localTools) {
120
+ deps.sendChannelError(ws, payload.sessionId, payload.channelName, "CHANNEL_NOT_LOCAL", "This server has no streamWebSocketLocalTools allowlist; iframe must poll the source tool directly.", message.requestId);
121
+ return;
122
+ }
123
+ // Phase B: a render IS the addressable unit. The prior
124
+ // reverse-index lookup collapses — `renderStore.get` resolves
125
+ // the render directly.
126
+ const stored = await deps.renderStore.get(payload.sessionId);
127
+ if (!stored || stored.id !== sub.sessionId) {
128
+ deps.sendChannelError(ws, payload.sessionId, payload.channelName, "SESSION_NOT_FOUND", `GguiSession '${payload.sessionId}' not found on subscriber '${sub.sessionId}'`, message.requestId);
129
+ return;
130
+ }
131
+ const render = stored.render;
132
+ // Channel entry resolution. mcpApps / system variants have no
133
+ // streamSpec so the field reads back as undefined — same code
134
+ // path as a component variant without the channel declared.
135
+ const streamSpec = render.type === "mcpApps" || render.type === "system" ? undefined : render.streamSpec;
136
+ const channelEntry = streamSpec?.[payload.channelName];
137
+ if (!channelEntry || !channelEntry.source) {
138
+ deps.sendChannelError(ws, payload.sessionId, payload.channelName, "CHANNEL_UNKNOWN", `streamSpec['${payload.channelName}'] not declared OR has no source.tool on render '${payload.sessionId}'`, message.requestId);
139
+ return;
140
+ }
141
+ const sourceTool = channelEntry.source.tool;
142
+ if (!localToolsAllowlist.has(sourceTool)) {
143
+ deps.sendChannelError(ws, payload.sessionId, payload.channelName, "CHANNEL_NOT_LOCAL", `source.tool '${sourceTool}' is not in streamWebSocketLocalTools; iframe must poll directly`, message.requestId);
144
+ return;
145
+ }
146
+ // Validation passed — schedule (or re-schedule) the polling loop.
147
+ const channelKey = `${payload.sessionId}:${payload.channelName}`;
148
+ // Idempotent replace: a reconnect that re-subscribes the same
149
+ // (render, channel) pair gets a fresh timer + zeroed seq. The
150
+ // client's gap-detection treats it as a new stream from the
151
+ // server's perspective; client-side reconnect logic owns
152
+ // continuity if the channel was declared `mode: 'append'`.
153
+ const existing = sub.channelSubs.get(channelKey);
154
+ if (existing) {
155
+ clearInterval(existing.timer);
156
+ sub.channelSubs.delete(channelKey);
157
+ }
158
+ const pollIntervalMs = clampPollInterval(payload.pollIntervalMs);
159
+ // Layered args: source.args defines defaults; client args override
160
+ // per ChannelSubscribePayload.args docstring.
161
+ const mergedArgs = {
162
+ ...(channelEntry.source.args ?? {}),
163
+ ...(payload.args ?? {}),
164
+ };
165
+ // Resolve the channelKey lookup at callback fire-time. The
166
+ // timer callback reads `sub.channelSubs.get(channelKey)` so
167
+ // `state` doesn't have to be referenced through a closure
168
+ // before it's actually inserted into the tracker. Also self-
169
+ // cleans on the zombie-timer path: if the subscription was
170
+ // already torn down (channel_unsubscribe / WS close / replace),
171
+ // the lookup returns undefined and we clearInterval ourselves.
172
+ const timer = setInterval(() => {
173
+ const live = sub.channelSubs.get(channelKey);
174
+ if (!live || !deps.subscribersByWs.has(sub.ws)) {
175
+ clearInterval(timer);
176
+ return;
177
+ }
178
+ void pollChannelOnce(sub, live);
179
+ }, pollIntervalMs);
180
+ const state = {
181
+ pollIntervalMs,
182
+ toolName: sourceTool,
183
+ sessionId: payload.sessionId,
184
+ channelName: payload.channelName,
185
+ args: mergedArgs,
186
+ seq: 0,
187
+ timer,
188
+ };
189
+ sub.channelSubs.set(channelKey, state);
190
+ deps.logger.info("render_channel_channel_subscribe", {
191
+ sessionId: sub.sessionId,
192
+ appId: sub.appId,
193
+ channelName: payload.channelName,
194
+ toolName: sourceTool,
195
+ pollIntervalMs,
196
+ });
197
+ // Eager-poll — fire one invocation immediately so the iframe sees
198
+ // an initial value without waiting `pollIntervalMs`. Matches the
199
+ // user-expected "subscribe then see data" cadence; the interval
200
+ // takes over from there.
201
+ void pollChannelOnce(sub, state);
202
+ }
203
+ function handleChannelUnsubscribe(_ws, sub, message) {
204
+ const payload = message.payload;
205
+ if (payload.sessionId !== sub.sessionId) {
206
+ // No-op silently — the canonical "spoof guard" code path is in
207
+ // channel_subscribe; unsubscribe gets no error frame to avoid
208
+ // leaking cross-render existence.
209
+ return;
210
+ }
211
+ const channelKey = `${payload.sessionId}:${payload.channelName}`;
212
+ const existing = sub.channelSubs.get(channelKey);
213
+ if (!existing)
214
+ return;
215
+ clearInterval(existing.timer);
216
+ sub.channelSubs.delete(channelKey);
217
+ deps.logger.info("render_channel_channel_unsubscribe", {
218
+ sessionId: sub.sessionId,
219
+ appId: sub.appId,
220
+ channelName: payload.channelName,
221
+ });
222
+ }
223
+ return { handleChannelSubscribe, handleChannelUnsubscribe };
224
+ }
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Internal shared types for the live-channel handler modules. NOT part
3
+ * of the package's public surface — everything publishable is exported
4
+ * (or re-exported) from `../ggui-session-channel.ts`.
5
+ */
6
+ import type { AuthResult, BufferedStreamEnvelope } from "@ggui-ai/mcp-server-core";
7
+ import type { JsonObject } from "@ggui-ai/protocol";
8
+ import type { WebSocket } from "ws";
9
+ /**
10
+ * A single connected subscriber (one client, one render). Held live in
11
+ * the per-channel subscriber map; torn down on socket close or explicit
12
+ * `close` message.
13
+ */
14
+ export interface Subscriber {
15
+ readonly ws: WebSocket;
16
+ readonly sessionId: string;
17
+ readonly appId: string;
18
+ readonly identity: AuthResult;
19
+ readonly connectedAt: number;
20
+ /**
21
+ * Largest outbound `seq` the initial replay (or subscribe snapshot)
22
+ * covered for this subscriber. Live fan-out skips envelopes with
23
+ * `seq <= replayCompletedSeq` to prevent double delivery — those
24
+ * were (or will be) delivered via the replay phase.
25
+ *
26
+ * For fresh subscribers (no `fromSeq`), this is the stream cursor
27
+ * at subscribe time; they never see the pre-existing buffer, only
28
+ * new deliveries.
29
+ */
30
+ readonly replayCompletedSeq: number;
31
+ /**
32
+ * Per-subscriber live-tail iterator from `streamFanout.subscribe`.
33
+ * Owned by the subscriber for its full lifetime; ending it (via
34
+ * `iter.return()`) terminates the pump loop AND unregisters from
35
+ * the StreamFanout. `unregister(ws)` is the single point that
36
+ * does this teardown.
37
+ */
38
+ readonly iter: AsyncIterator<BufferedStreamEnvelope>;
39
+ /**
40
+ * Active `channel_subscribe` polling loops for this subscriber.
41
+ * Keyed by `${sessionId}:${channelName}` so a reconnect that
42
+ * re-subscribes to the same (render, channel) pair replaces the
43
+ * existing timer rather than minting a duplicate (idempotent
44
+ * semantics on the wire). Torn down en masse by `unregister(ws)` on
45
+ * WS close.
46
+ *
47
+ * Populated by the `channel_subscribe` handler when the composing
48
+ * host wired a `streamWebSocketLocalTools` allowlist; empty
49
+ * otherwise.
50
+ */
51
+ readonly channelSubs: Map<string, ChannelSubscriptionState>;
52
+ }
53
+ /**
54
+ * Per-(subscriber, sessionId, channelName) polling-loop state.
55
+ * Created on `channel_subscribe` accept, torn down on `channel_unsubscribe`
56
+ * / WS close / re-subscribe-replace.
57
+ *
58
+ * Server-side polling of `streamSpec[ch].source.tool` for the
59
+ * subset of tools the operator listed on `streamWebSocketLocalTools`.
60
+ * Channels whose `source.tool` isn't in the allowlist are rejected
61
+ * with `CHANNEL_NOT_LOCAL` so the iframe falls back to direct polling
62
+ * over the MCP host proxy.
63
+ */
64
+ export interface ChannelSubscriptionState {
65
+ /** Server-clamped poll cadence in ms (within configured floor/ceiling). */
66
+ readonly pollIntervalMs: number;
67
+ /** Source tool name resolved from `streamSpec[channelName].source.tool`. */
68
+ readonly toolName: string;
69
+ /** GguiSession this subscription is bound to (for fan-out scoping). */
70
+ readonly sessionId: string;
71
+ /** Channel name (key into `streamSpec`). */
72
+ readonly channelName: string;
73
+ /**
74
+ * Merged args used on each poll call. Layered as `{...source.args,
75
+ * ...client.args}` so client wins on key collisions — matches the
76
+ * docstring on `ChannelSubscribePayload.args`.
77
+ */
78
+ readonly args: JsonObject;
79
+ /**
80
+ * Channel-scoped monotonic counter stamped into every
81
+ * `channel_payload` frame's `seq`. Starts at 1 and advances per
82
+ * successful poll for client-side gap detection.
83
+ */
84
+ seq: number;
85
+ /** Active `setInterval` handle — cleared on teardown. */
86
+ readonly timer: ReturnType<typeof setInterval>;
87
+ }
88
+ /**
89
+ * Upgrade-time piggyback slots on the Node request object — the
90
+ * standard ws per-request pattern. The upgrade phase resolves identity
91
+ * (and, for console-cookie upgrades, the bound render/app) BEFORE the
92
+ * WebSocket exists, stashes them on the request, and the `connection`
93
+ * handler picks them up to seed the pre-subscribe bindings.
94
+ */
95
+ export interface UpgradeBindings {
96
+ __gguiIdentity?: AuthResult;
97
+ __gguiCookieBound?: {
98
+ sessionId: string;
99
+ appId: string;
100
+ };
101
+ }
102
+ //# sourceMappingURL=internal-types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"internal-types.d.ts","sourceRoot":"","sources":["../../src/ggui-session-channel/internal-types.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,KAAK,EAAE,UAAU,EAAE,sBAAsB,EAAE,MAAM,0BAA0B,CAAC;AACnF,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,mBAAmB,CAAC;AACpD,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,IAAI,CAAC;AAEpC;;;;GAIG;AACH,MAAM,WAAW,UAAU;IACzB,QAAQ,CAAC,EAAE,EAAE,SAAS,CAAC;IACvB,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,QAAQ,EAAE,UAAU,CAAC;IAC9B,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;IAC7B;;;;;;;;;OASG;IACH,QAAQ,CAAC,kBAAkB,EAAE,MAAM,CAAC;IACpC;;;;;;OAMG;IACH,QAAQ,CAAC,IAAI,EAAE,aAAa,CAAC,sBAAsB,CAAC,CAAC;IACrD;;;;;;;;;;;OAWG;IACH,QAAQ,CAAC,WAAW,EAAE,GAAG,CAAC,MAAM,EAAE,wBAAwB,CAAC,CAAC;CAC7D;AAED;;;;;;;;;;GAUG;AACH,MAAM,WAAW,wBAAwB;IACvC,2EAA2E;IAC3E,QAAQ,CAAC,cAAc,EAAE,MAAM,CAAC;IAChC,4EAA4E;IAC5E,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,uEAAuE;IACvE,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,4CAA4C;IAC5C,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;IAC7B;;;;OAIG;IACH,QAAQ,CAAC,IAAI,EAAE,UAAU,CAAC;IAC1B;;;;OAIG;IACH,GAAG,EAAE,MAAM,CAAC;IACZ,yDAAyD;IACzD,QAAQ,CAAC,KAAK,EAAE,UAAU,CAAC,OAAO,WAAW,CAAC,CAAC;CAChD;AAED;;;;;;GAMG;AACH,MAAM,WAAW,eAAe;IAC9B,cAAc,CAAC,EAAE,UAAU,CAAC;IAC5B,iBAAiB,CAAC,EAAE;QAAE,SAAS,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,CAAC;CAC1D"}
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Internal shared types for the live-channel handler modules. NOT part
3
+ * of the package's public surface — everything publishable is exported
4
+ * (or re-exported) from `../ggui-session-channel.ts`.
5
+ */
6
+ export {};