@ggui-ai/mcp-server 0.2.0-alpha.4 → 0.4.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 +2 -2
  9. package/dist/build-mcp.d.ts.map +1 -1
  10. package/dist/build-mcp.js +64 -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 +204 -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 +24 -24
  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 -221
  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 +154 -177
  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,165 @@
1
+ /**
2
+ * Subscribe / credential-path family for the live channel — upgrade-
3
+ * time identity resolution across the three auth planes (bearer via
4
+ * the AuthAdapter, ws-token bootstrap, console cookie) and the
5
+ * `subscribe` handler itself: version handshake, bootstrap verify +
6
+ * reconnect-credential mint, identity-default appId resolution,
7
+ * dev-mode render provisioning, replay, registration, and the ack.
8
+ */
9
+ import type { AuthAdapter, AuthResult, GguiSessionStore, GguiSessionStreamBuffer, StreamFanout } from "@ggui-ai/mcp-server-core";
10
+ import type { WebSocketMessage } from "@ggui-ai/protocol/transport/websocket";
11
+ import type { IncomingMessage } from "node:http";
12
+ import type { WebSocket } from "ws";
13
+ import type { Logger } from "../logger.js";
14
+ import type { Outbound } from "./outbound.js";
15
+ import type { SubscriberLifecycle } from "./subscriber-lifecycle.js";
16
+ /**
17
+ * Bootstrap-auth plumbing for the live-channel endpoint.
18
+ *
19
+ * The channel accepts a bootstrap credential on the `subscribe`
20
+ * message (`SubscribePayload.wsToken`). When present:
21
+ *
22
+ * 1. `verify(token)` is called. Must return the bound
23
+ * `{sessionId, appId}` on success, or `null` on any failure
24
+ * (invalid sig, expired, wrong kind, replayed, etc.).
25
+ * 2. The bound `sessionId` MUST match the one on the subscribe
26
+ * payload. Mismatches are rejected with a clean error.
27
+ * 3. On success, the server mints a reconnect credential via
28
+ * `issueSessionToken(sessionId, appId)` and returns it in
29
+ * `AckPayload.sessionToken`. The iframe stores this for WS
30
+ * reconnects via the normal bearer path.
31
+ *
32
+ * Bootstrap auth is MUTUALLY EXCLUSIVE with the upstream `AuthAdapter`
33
+ * bearer path at subscribe time — when a bootstrap token is present,
34
+ * the identity resolved at the HTTP upgrade is IGNORED in favor of
35
+ * the bootstrap-derived identity. This is intentional: MCP Apps
36
+ * iframes don't have a long-lived bearer; the bootstrap IS the auth.
37
+ */
38
+ /**
39
+ * Verify failure shape — distinguished so the channel server can map
40
+ * `'expired'` to `BOOTSTRAP_EXPIRED` (client SHOULD refresh) vs
41
+ * `'invalid'` to `BOOTSTRAP_INVALID` (client MUST re-handshake).
42
+ *
43
+ * G14 (2026-05-23): bootstrap envelopes are no longer single-use. A
44
+ * signature-valid + unexpired token authenticates EVERY subscribe
45
+ * within the TTL window; transient WS drops reconnect without a fresh
46
+ * handshake. Past expiry, the iframe MAY refresh via the
47
+ * {@link refresh} surface; past the refresh window, fresh handshake.
48
+ */
49
+ export type GguiSessionChannelBootstrapVerifyResult = {
50
+ readonly ok: true;
51
+ readonly sessionId: string;
52
+ readonly appId: string;
53
+ } | {
54
+ readonly ok: false;
55
+ readonly reason: "expired" | "invalid";
56
+ };
57
+ /**
58
+ * Result of {@link GguiSessionChannelBootstrap.refresh}.
59
+ *
60
+ * - `ok: true`: caller swaps the old envelope for `token` and resumes.
61
+ * - `ok: false`: caller MUST re-handshake (refresh window closed,
62
+ * tampered envelope, etc.).
63
+ */
64
+ export type GguiSessionChannelBootstrapRefreshResult = {
65
+ readonly ok: true;
66
+ readonly token: string;
67
+ readonly expiresAt: string;
68
+ } | {
69
+ readonly ok: false;
70
+ readonly reason: "window_closed" | "invalid";
71
+ };
72
+ export interface GguiSessionChannelBootstrap {
73
+ /**
74
+ * Verify a `SubscribePayload.wsToken` token.
75
+ *
76
+ * Returns the bound identity on success, or a discriminated failure.
77
+ * The channel server maps `'expired'` to `BOOTSTRAP_EXPIRED` so the
78
+ * iframe can branch on refresh-vs-rehandshake, and `'invalid'` to
79
+ * `BOOTSTRAP_INVALID` for tamper / format / kind failures (no
80
+ * refresh on those).
81
+ */
82
+ verify(token: string): GguiSessionChannelBootstrapVerifyResult;
83
+ /**
84
+ * Mint a longer-lived reconnect credential to return in
85
+ * `AckPayload.sessionToken`. Called only after a successful
86
+ * `verify()` on a bootstrap subscribe.
87
+ */
88
+ issueSessionToken(sessionId: string, appId: string): string;
89
+ /**
90
+ * Refresh a (possibly-expired-but-signature-valid) bootstrap envelope
91
+ * into a new envelope with a fresh TTL. Used by the
92
+ * `ggui_runtime_refresh_ws_token` MCP tool — iframes that see their
93
+ * bootstrap drift out of the TTL window swap in the refreshed
94
+ * envelope without going back through `ggui_render`.
95
+ *
96
+ * Stateless: verifies HMAC against the same secret used at mint,
97
+ * checks the refresh window against the ORIGINAL `iat`, and mints
98
+ * a fresh bootstrap envelope bound to the SAME `(sessionId, appId)`.
99
+ * Past the refresh window the result is `{ok:false, reason:
100
+ * 'window_closed'}`; tampered envelopes are `{ok:false, reason:
101
+ * 'invalid'}`.
102
+ */
103
+ refresh(token: string): GguiSessionChannelBootstrapRefreshResult;
104
+ }
105
+ /**
106
+ * Cookie-based authentication for the live-channel upgrade. Used
107
+ * exclusively by the same-origin console viewer; see
108
+ * `console-auth.ts` for the single consumer today.
109
+ */
110
+ export interface GguiSessionChannelCookieAuth {
111
+ /**
112
+ * Read the raw cookie value for THIS server's console cookie
113
+ * from the incoming request headers. Returns `null` when the
114
+ * cookie is absent or malformed.
115
+ */
116
+ readCookie(headers: import("node:http").IncomingHttpHeaders): string | null;
117
+ /**
118
+ * Verify a cookie value and return the bound render/app. Returns
119
+ * `null` on any failure (signature, expiry, wrong kind). Never
120
+ * throws.
121
+ */
122
+ verify(cookieValue: string): {
123
+ sessionId: string;
124
+ appId: string;
125
+ } | null;
126
+ }
127
+ export interface SubscribeDeps {
128
+ readonly logger: Logger;
129
+ /** Same `AuthAdapter` the `/mcp` endpoint uses (bearer plane). */
130
+ readonly auth: AuthAdapter;
131
+ readonly renderStore: GguiSessionStore;
132
+ /** Outbound stream replay buffer — snapshot cursor + replay reads. */
133
+ readonly streamBuffer: GguiSessionStreamBuffer;
134
+ /** Live-tail pub/sub seam — one iterator minted per subscriber. */
135
+ readonly streamFanout: StreamFanout;
136
+ /** Bootstrap (ws-token) plumbing — see `GguiSessionChannelOptions.bootstrap`. */
137
+ readonly bootstrap?: GguiSessionChannelBootstrap;
138
+ /** Console cookie plumbing — see `GguiSessionChannelOptions.cookieAuth`. */
139
+ readonly cookieAuth?: GguiSessionChannelCookieAuth;
140
+ /** Identity → tenant appId mapping — see `GguiSessionChannelOptions.appIdFromIdentity`. */
141
+ readonly appIdFromIdentity?: (result: AuthResult) => string;
142
+ /** Version-handshake policy — see `GguiSessionChannelOptions.versionPolicy`. */
143
+ readonly versionPolicy?: "advisory" | "reject";
144
+ readonly send: Outbound["send"];
145
+ readonly sendError: Outbound["sendError"];
146
+ readonly register: SubscriberLifecycle["register"];
147
+ }
148
+ export interface SubscribeHandlers {
149
+ /**
150
+ * Resolve the connecting client's identity at HTTP-upgrade time,
151
+ * across the three auth planes (bootstrap query-gate, console
152
+ * cookie, bearer / `?token=`). Throws `UnauthenticatedError` to
153
+ * reject the upgrade with 401.
154
+ */
155
+ resolveIdentityFromUpgrade(req: IncomingMessage): Promise<AuthResult>;
156
+ /** Handle the `subscribe` message — see the module docstring. */
157
+ handleSubscribe(ws: WebSocket, identity: AuthResult, message: WebSocketMessage & {
158
+ type: "subscribe";
159
+ }, cookieBound?: {
160
+ readonly sessionId: string;
161
+ readonly appId: string;
162
+ }): Promise<void>;
163
+ }
164
+ export declare function createSubscribeHandlers(deps: SubscribeDeps): SubscribeHandlers;
165
+ //# sourceMappingURL=subscribe.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"subscribe.d.ts","sourceRoot":"","sources":["../../src/ggui-session-channel/subscribe.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,370 @@
1
+ /**
2
+ * Subscribe / credential-path family for the live channel — upgrade-
3
+ * time identity resolution across the three auth planes (bearer via
4
+ * the AuthAdapter, ws-token bootstrap, console cookie) and the
5
+ * `subscribe` handler itself: version handshake, bootstrap verify +
6
+ * reconnect-credential mint, identity-default appId resolution,
7
+ * dev-mode render provisioning, replay, registration, and the ack.
8
+ */
9
+ import { PROTOCOL_SCHEMA_VERSION, UPGRADE_REQUIRED } from "@ggui-ai/protocol";
10
+ import { defaultAppIdFromIdentity, resolveIdentityFromHeaders, UnauthenticatedError, } from "../auth.js";
11
+ export function createSubscribeHandlers(deps) {
12
+ async function resolveIdentityFromUpgrade(req) {
13
+ const url = new URL(req.url ?? "/", "http://localhost");
14
+ // WS-token gate: when `?wsToken=` is present AND the channel is
15
+ // configured with ws-token-auth plumbing, skip the AuthAdapter
16
+ // entirely at upgrade. The real identity is established in
17
+ // handleSubscribe when the subscribe payload's `wsToken` is
18
+ // verified. This is how MCP Apps iframes connect — they can't set
19
+ // Authorization headers, and the token model is subscribe-scoped
20
+ // anyway.
21
+ //
22
+ // URL-gate here is NOT verification — it's a "don't reject the
23
+ // upgrade for missing bearer" signal. The real verify runs at
24
+ // subscribe time; an invalid token reaches that point and is
25
+ // rejected with BOOTSTRAP_INVALID.
26
+ if (deps.bootstrap && url.searchParams.has("wsToken")) {
27
+ return {
28
+ identity: {
29
+ kind: "user",
30
+ userId: "__bootstrap_pending__",
31
+ workspaceId: "__bootstrap_pending__",
32
+ roles: [],
33
+ },
34
+ source: "apikey",
35
+ };
36
+ }
37
+ // Embedded-ui cookie gate. Consulted ONLY when the channel is
38
+ // configured with cookie-auth plumbing AND no bootstrap is in
39
+ // play. Unlike bootstrap, cookies ARE verified here: the single
40
+ // consumer (console SPA) sets the cookie out-of-band via
41
+ // `POST /ggui/console/session-cookie` and we do want to
42
+ // reject the upgrade cleanly (HTTP 401 → browser WS error) when
43
+ // the cookie is stale/missing, not carry a doomed handshake into
44
+ // subscribe where the error surface is worse.
45
+ //
46
+ // On success, we stash the bound `{sessionId, appId}` on the
47
+ // request so `handleSubscribe` can enforce that the subscribe
48
+ // payload targets exactly those values. No synthesis from the
49
+ // AuthAdapter — cookies ARE the auth signal.
50
+ if (deps.cookieAuth) {
51
+ const raw = deps.cookieAuth.readCookie(req.headers);
52
+ if (raw) {
53
+ const bound = deps.cookieAuth.verify(raw);
54
+ if (bound) {
55
+ req.__gguiCookieBound = bound;
56
+ return {
57
+ identity: { kind: "builder" },
58
+ source: "apikey",
59
+ };
60
+ }
61
+ // Cookie present but invalid — do NOT fall through to the
62
+ // bearer path. An invalid cookie is a same-origin user error,
63
+ // not a pass-through condition.
64
+ throw new UnauthenticatedError("console cookie invalid");
65
+ }
66
+ // No cookie present → fall through to bearer path below. Mixed
67
+ // deployments (pairing-token bearer + same-origin cookie for
68
+ // viewer) are legal.
69
+ }
70
+ // Browsers can't set Authorization on native WebSocket. Fall back
71
+ // to `?token=<jwt>` for web clients, matching the convention most
72
+ // render-channel endpoints ship with. Server-side clients (Node
73
+ // `ws`, tests) continue to set the header directly.
74
+ if (!req.headers["authorization"]) {
75
+ const token = url.searchParams.get("token");
76
+ if (token) {
77
+ req.headers["authorization"] = `Bearer ${token}`;
78
+ }
79
+ }
80
+ return resolveIdentityFromHeaders(deps.auth, req.headers, req.socket?.remoteAddress ?? undefined);
81
+ }
82
+ async function handleSubscribe(ws, identity, message, cookieBound) {
83
+ const payload = message.payload;
84
+ // Protocol-version handshake. Opt-in on the client side: absent
85
+ // `supportedVersions` is legacy-pass-through. When present,
86
+ // require the server's PROTOCOL_SCHEMA_VERSION to be in the
87
+ // declared set — otherwise emit UPGRADE_REQUIRED.
88
+ //
89
+ // - 'reject' (default): emit + close the connection. Canonical
90
+ // posture for first-party servers.
91
+ // - 'advisory' (opt-out): emit + keep the connection
92
+ // open (but stop the subscribe; no ack, no render work).
93
+ // Clients that ignore the code continue exactly as
94
+ // pre-handshake.
95
+ //
96
+ // Placed FIRST — before bootstrap verify (which consumes the
97
+ // single-use bootstrap token per the GguiSessionChannelBootstrap
98
+ // docstring) and before render lookup/creation (DB work).
99
+ // Bootstrap iframes with a version mismatch must retry with a
100
+ // fresh bootstrap token; burning the token on a mismatch the
101
+ // client could detect by reading the version-negotiation spec
102
+ // would be a footgun.
103
+ if (Array.isArray(payload.supportedVersions) &&
104
+ payload.supportedVersions.length > 0 &&
105
+ !payload.supportedVersions.includes(PROTOCOL_SCHEMA_VERSION)) {
106
+ const policy = deps.versionPolicy ?? "reject";
107
+ deps.sendError(ws, UPGRADE_REQUIRED, `Server speaks ${PROTOCOL_SCHEMA_VERSION}; client declared ` +
108
+ `supportedVersions=[${payload.supportedVersions.join(", ")}].`, message.requestId, {
109
+ serverVersion: PROTOCOL_SCHEMA_VERSION,
110
+ clientSupportedVersions: payload.supportedVersions,
111
+ policy,
112
+ });
113
+ deps.logger.warn("render_channel_version_mismatch", {
114
+ sessionId: payload.sessionId,
115
+ appId: payload.appId,
116
+ serverVersion: PROTOCOL_SCHEMA_VERSION,
117
+ clientSupportedVersions: payload.supportedVersions,
118
+ policy,
119
+ });
120
+ if (policy === "reject") {
121
+ try {
122
+ ws.close();
123
+ }
124
+ catch {
125
+ // best-effort — socket may already be closing
126
+ }
127
+ }
128
+ return;
129
+ }
130
+ // WS-token-auth path. When `payload.wsToken` is present, the MCP
131
+ // Apps iframe is asking us to authenticate it via the short-lived
132
+ // token minted by `ggui_render`. This REPLACES the upgrade-time
133
+ // AuthAdapter identity — iframes don't carry bearer tokens.
134
+ // Mutually-exclusive on purpose.
135
+ let effectiveIdentity = identity;
136
+ let mintedSessionToken;
137
+ let tokenBoundAppId;
138
+ if (typeof payload.wsToken === "string" && payload.wsToken.length > 0) {
139
+ if (!deps.bootstrap) {
140
+ deps.sendError(ws, "BOOTSTRAP_NOT_SUPPORTED", "This server was not configured with ws-token-auth plumbing", message.requestId);
141
+ return;
142
+ }
143
+ const verifyResult = deps.bootstrap.verify(payload.wsToken);
144
+ if (!verifyResult.ok) {
145
+ deps.logger.warn("render_channel_bootstrap_rejected", {
146
+ sessionId: payload.sessionId,
147
+ appId: payload.appId,
148
+ reason: verifyResult.reason,
149
+ });
150
+ // G14 (2026-05-23): distinguish `expired` from `invalid` so the
151
+ // iframe-side handler can branch on refresh-vs-rehandshake.
152
+ // Tamper / format / kind failures collapse into BOOTSTRAP_INVALID
153
+ // (no refresh path); expired-but-signed envelopes emit the
154
+ // dedicated BOOTSTRAP_EXPIRED so the client knows to call
155
+ // `ggui_runtime_refresh_ws_token`.
156
+ if (verifyResult.reason === "expired") {
157
+ deps.sendError(ws, "BOOTSTRAP_EXPIRED", "Bootstrap token expired — call ggui_runtime_refresh_ws_token or re-handshake", message.requestId);
158
+ }
159
+ else {
160
+ deps.sendError(ws, "BOOTSTRAP_INVALID", "Bootstrap token invalid (bad signature, malformed, or wrong kind)", message.requestId);
161
+ }
162
+ return;
163
+ }
164
+ const bound = { sessionId: verifyResult.sessionId, appId: verifyResult.appId };
165
+ if (bound.sessionId !== payload.sessionId) {
166
+ deps.sendError(ws, "BOOTSTRAP_SESSION_MISMATCH", `Bootstrap token is bound to render '${bound.sessionId}' but subscribe targets '${payload.sessionId}'`, message.requestId);
167
+ return;
168
+ }
169
+ // `payload.appId` is OPTIONAL on the wire (SPEC §12.2): under a
170
+ // bound token, absence resolves to the token's bound appId — the
171
+ // token binding IS the identity-default. Only a PRESENT value
172
+ // that contradicts the binding is a mismatch.
173
+ if (payload.appId !== undefined && payload.appId !== bound.appId) {
174
+ deps.sendError(ws, "BOOTSTRAP_APP_MISMATCH", `Bootstrap token is bound to app '${bound.appId}' but subscribe targets '${payload.appId}'`, message.requestId);
175
+ return;
176
+ }
177
+ tokenBoundAppId = bound.appId;
178
+ // Synthesize a minimal AuthResult from the bootstrap claims.
179
+ // The subscriber row needs an identity for logging and roster
180
+ // inspection; the bootstrap-derived identity is a first-class
181
+ // citizen for the lifetime of this subscription.
182
+ effectiveIdentity = {
183
+ identity: {
184
+ kind: "user",
185
+ userId: bound.sessionId,
186
+ workspaceId: bound.appId,
187
+ roles: [],
188
+ },
189
+ source: "apikey",
190
+ };
191
+ // Mint the reconnect credential now — before create/observe
192
+ // work — so a downstream failure doesn't leave the client with
193
+ // no way to resume.
194
+ mintedSessionToken = deps.bootstrap.issueSessionToken(bound.sessionId, bound.appId);
195
+ deps.logger.info("render_channel_bootstrap_accepted", {
196
+ sessionId: bound.sessionId,
197
+ appId: bound.appId,
198
+ });
199
+ }
200
+ // Identity-default appId resolution (SPEC §12.2): `payload.appId`
201
+ // is optional on the wire. Absent ⇒ resolve it from the
202
+ // connection's authenticated identity BEFORE any store work —
203
+ // exactly the rule the `/mcp` endpoint applies: a credential
204
+ // binding wins (wsToken binds `(sessionId, appId)`; the console
205
+ // cookie binds the same pair), else the deployment's identity →
206
+ // appId mapping (`appIdFromIdentity`, defaulting to
207
+ // `defaultAppIdFromIdentity`). The resolved value then flows
208
+ // through the EXISTING tenancy gate + provisioning below — never
209
+ // an `undefined` tenant on a stored row.
210
+ const effectiveAppId = payload.appId ??
211
+ tokenBoundAppId ??
212
+ cookieBound?.appId ??
213
+ (deps.appIdFromIdentity ?? defaultAppIdFromIdentity)(effectiveIdentity);
214
+ // Dev-mode render provisioning: look up first; if not present,
215
+ // create with the client-provided id via the widened
216
+ // CreateGguiSessionInput.id seam. Matches the hosted model's shape
217
+ // (agent creates via ggui_render → client subscribes) in a single
218
+ // step — production deployments tighten this by supplying an
219
+ // AuthAdapter that mints render-scoped tokens on render.
220
+ let stored = await deps.renderStore.get(payload.sessionId);
221
+ if (stored) {
222
+ if (stored.appId !== effectiveAppId) {
223
+ deps.sendError(ws, "APP_MISMATCH", `GguiSession ${payload.sessionId} belongs to a different app`, message.requestId);
224
+ return;
225
+ }
226
+ }
227
+ else {
228
+ try {
229
+ stored = await deps.renderStore.create({
230
+ id: payload.sessionId,
231
+ appId: effectiveAppId,
232
+ });
233
+ }
234
+ catch (err) {
235
+ deps.sendError(ws, "SESSION_CREATE_FAILED", err instanceof Error ? err.message : String(err), message.requestId);
236
+ return;
237
+ }
238
+ }
239
+ // Snapshot the outbound-stream cursor BEFORE registering the
240
+ // subscriber. Any concurrent producer that calls sendToGguiSession
241
+ // between here and registration gets seq > snapshotSeq, so the
242
+ // subscriber will receive it via live fan-out (not via replay).
243
+ //
244
+ // This is race-safe in single-threaded JS: the next few lines run
245
+ // synchronously up to `register(sub)`, and fan-out's per-subscriber
246
+ // `seq <= replayCompletedSeq` guard takes care of the window.
247
+ const snapshotSeq = await deps.streamBuffer.currentSeq(stored.id);
248
+ // Phase B: a render IS the addressable unit, so the active item
249
+ // is the resolved render's visible-bits surface itself.
250
+ const activeItem = stored.render;
251
+ // Reconnect: `fromSeq` present → replay per policy on declared
252
+ // AND reserved channels. Fresh subscribe: `fromSeq` absent →
253
+ // call with `fromSeq=0` + NO spec, so the buffer's spec-channel
254
+ // walk contributes nothing (preserving the "initial state comes
255
+ // from ack.render.props; stream channels are for updates after"
256
+ // doctrine for agent-declared channels) but the reserved-channel
257
+ // walk still surfaces server-pushed state that landed before the
258
+ // subscriber attached.
259
+ const activeStreamSpec = activeItem.type !== "mcpApps" && activeItem.type !== "system"
260
+ ? activeItem.streamSpec
261
+ : undefined;
262
+ const replay = payload.fromSeq !== undefined
263
+ ? await deps.streamBuffer.replay(stored.id, payload.fromSeq, activeStreamSpec)
264
+ : await deps.streamBuffer.replay(stored.id, 0, undefined);
265
+ // Subscribe to the StreamFanout BEFORE constructing the Subscriber:
266
+ // the seam returns an AsyncIterable whose iterator we hand off; the
267
+ // pump loop in `register` consumes it. Eager registration on the
268
+ // seam side means any concurrent `streamFanout.publish` from this
269
+ // point onward queues into our iterator — paired with the
270
+ // replayCompletedSeq cursor below, that's race-free.
271
+ const fanoutIter = deps.streamFanout.subscribe(stored.id)[Symbol.asyncIterator]();
272
+ const sub = {
273
+ ws,
274
+ sessionId: stored.id,
275
+ appId: stored.appId,
276
+ identity: effectiveIdentity,
277
+ connectedAt: Date.now(),
278
+ replayCompletedSeq: snapshotSeq,
279
+ iter: fanoutIter,
280
+ // Per-subscriber channel-subscribe tracker. Populated
281
+ // lazily by the `channel_subscribe` handler when the operator
282
+ // wired `streamWebSocketLocalTools`; stays empty otherwise.
283
+ channelSubs: new Map(),
284
+ };
285
+ deps.register(sub);
286
+ deps.logger.info("render_channel_subscribed", {
287
+ sessionId: stored.id,
288
+ appId: stored.appId,
289
+ identityKind: effectiveIdentity.identity.kind,
290
+ fromSeq: payload.fromSeq,
291
+ snapshotSeq,
292
+ replayCount: replay?.envelopes.length ?? 0,
293
+ replayTruncated: replay?.truncated ?? false,
294
+ bootstrap: mintedSessionToken !== undefined,
295
+ });
296
+ const ackPayload = {
297
+ sequence: stored.eventSequence,
298
+ timestamp: Date.now(),
299
+ session: stored.render,
300
+ streamSeq: snapshotSeq,
301
+ // Advertise the server's protocol version on every successful
302
+ // subscribe ack (SPEC §11.2.2). Clients whose
303
+ // CLIENT_SUPPORTED_VERSIONS doesn't contain this string surface
304
+ // UpgradeRequiredError to their caller; clients that don't wire
305
+ // the handshake ignore the field (legacy-pass-through).
306
+ serverVersion: PROTOCOL_SCHEMA_VERSION,
307
+ ...(replay?.truncated ? { replayTruncated: true } : {}),
308
+ ...(mintedSessionToken !== undefined ? { sessionToken: mintedSessionToken } : {}),
309
+ };
310
+ deps.send(ws, {
311
+ type: "ack",
312
+ payload: ackPayload,
313
+ ...(message.requestId ? { requestId: message.requestId } : {}),
314
+ });
315
+ // R7 — GguiSessionEvent ledger replay. When `payload.sinceSequence` is
316
+ // present, fetch events with `seq > sinceSequence` from the per-
317
+ // render ledger and emit each as a `render_event` wire frame
318
+ // BEFORE the per-channel stream-buffer replay. Consumers dispatch
319
+ // by `event.type` to fold the wire-frame-equivalent handler
320
+ // (render/props_update/etc.) — same cursor model as the HTTP
321
+ // `/api/sessions/:id/events?sinceSequence=N` endpoint.
322
+ //
323
+ // Horizon gate: a cursor below the server's replay horizon OR
324
+ // above `lastSequence` (stale from a different deployment) emits
325
+ // an error frame with `code: 'REPLAY_HORIZON_PASSED'` and skips
326
+ // the replay. Client recovery: re-mount from a fresh /state read.
327
+ if (payload.sinceSequence !== undefined) {
328
+ const sinceSeq = payload.sinceSequence;
329
+ if (sinceSeq < 0 || !Number.isInteger(sinceSeq)) {
330
+ deps.sendError(ws, "INVALID_SINCE_SEQUENCE", "sinceSequence must be a non-negative integer", message.requestId);
331
+ }
332
+ else {
333
+ const ledger = await deps.renderStore.listEventsSince(stored.id, sinceSeq,
334
+ // Server-side cap matches the HTTP route's default (100).
335
+ // Stress + replay-from-zero workloads cap here.
336
+ 100);
337
+ if (ledger === null) {
338
+ // GguiSession disappeared between resolve and ledger read —
339
+ // already handled by the broader error envelope path; nothing
340
+ // to do here.
341
+ }
342
+ else if (sinceSeq > ledger.lastSequence || sinceSeq < ledger.horizonSeq) {
343
+ deps.sendError(ws, "REPLAY_HORIZON_PASSED", `cursor ${sinceSeq} is outside replayable range [${ledger.horizonSeq}, ${ledger.lastSequence}]`, message.requestId, { currentSequence: ledger.lastSequence });
344
+ }
345
+ else {
346
+ for (const event of ledger.events) {
347
+ // GguiSessionEvent is now the wire-shape ledger primitive
348
+ // (Wave 7 of flatten-render-identity, 2026-05-28); no
349
+ // projection — emit the store's row directly.
350
+ deps.send(ws, {
351
+ type: "render_event",
352
+ payload: event,
353
+ });
354
+ }
355
+ }
356
+ }
357
+ }
358
+ // Send replay frames AFTER the ack. Ordering by `seq` ASC — the
359
+ // buffer returns them pre-sorted. Client sees ack(streamSeq=N) →
360
+ // up to N replay `data` frames → live tail (seq > N). No explicit
361
+ // "replay end" marker is needed; the client uses envelope.seq as
362
+ // the single source of truth for ordering.
363
+ if (replay) {
364
+ for (const env of replay.envelopes) {
365
+ deps.send(ws, { type: "data", payload: env });
366
+ }
367
+ }
368
+ }
369
+ return { resolveIdentityFromUpgrade, handleSubscribe };
370
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * WS subscriber lifecycle for the live channel — registration into the
3
+ * shared subscriber set, the per-subscriber live-tail pump loop, and
4
+ * the symmetric teardown path (`unregister`) that ends the pump,
5
+ * unhooks the StreamFanout subscription, and clears every
6
+ * `channel_subscribe` polling loop the subscriber owned.
7
+ *
8
+ * Owns the per-render subscriber counter that drives the
9
+ * `onFirstSubscriber` / `onLastSubscriberGone` 0↔1 transition hooks —
10
+ * no other module reads it.
11
+ */
12
+ import type { WebSocketMessage } from "@ggui-ai/protocol/transport/websocket";
13
+ import type { WebSocket } from "ws";
14
+ import type { Logger } from "../logger.js";
15
+ import type { Subscriber } from "./internal-types.js";
16
+ export interface SubscriberLifecycleDeps {
17
+ readonly logger: Logger;
18
+ /** Flat set of all live WS subscribers — membership owned HERE. */
19
+ readonly wsSubscribers: Set<Subscriber>;
20
+ /** ws → subscriber reverse index so socket-close can look up cheaply. */
21
+ readonly subscribersByWs: WeakMap<WebSocket, Subscriber>;
22
+ /** Low-level wire write from the outbound module. */
23
+ readonly send: (ws: WebSocket, msg: WebSocketMessage) => void;
24
+ /** 0→1 transition hook — see `GguiSessionChannelOptions.onFirstSubscriber`. */
25
+ readonly onFirstSubscriber?: (sessionId: string) => void;
26
+ /** 1→0 transition hook — see `GguiSessionChannelOptions.onLastSubscriberGone`. */
27
+ readonly onLastSubscriberGone?: (sessionId: string) => void;
28
+ }
29
+ export interface SubscriberLifecycle {
30
+ /** Add a subscriber to the live set and start its pump loop. */
31
+ register(sub: Subscriber): void;
32
+ /**
33
+ * Tear down the subscriber bound to `ws` (if any): remove from the
34
+ * live set, end the fanout iterator (terminates the pump), clear
35
+ * every channel-subscribe polling timer. Idempotent.
36
+ */
37
+ unregister(ws: WebSocket): void;
38
+ }
39
+ export declare function createSubscriberLifecycle(deps: SubscriberLifecycleDeps): SubscriberLifecycle;
40
+ //# sourceMappingURL=subscriber-lifecycle.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"subscriber-lifecycle.d.ts","sourceRoot":"","sources":["../../src/ggui-session-channel/subscriber-lifecycle.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH,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,EAAE,UAAU,EAAE,MAAM,qBAAqB,CAAC;AAEtD,MAAM,WAAW,uBAAuB;IACtC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,mEAAmE;IACnE,QAAQ,CAAC,aAAa,EAAE,GAAG,CAAC,UAAU,CAAC,CAAC;IACxC,yEAAyE;IACzE,QAAQ,CAAC,eAAe,EAAE,OAAO,CAAC,SAAS,EAAE,UAAU,CAAC,CAAC;IACzD,qDAAqD;IACrD,QAAQ,CAAC,IAAI,EAAE,CAAC,EAAE,EAAE,SAAS,EAAE,GAAG,EAAE,gBAAgB,KAAK,IAAI,CAAC;IAC9D,+EAA+E;IAC/E,QAAQ,CAAC,iBAAiB,CAAC,EAAE,CAAC,SAAS,EAAE,MAAM,KAAK,IAAI,CAAC;IACzD,kFAAkF;IAClF,QAAQ,CAAC,oBAAoB,CAAC,EAAE,CAAC,SAAS,EAAE,MAAM,KAAK,IAAI,CAAC;CAC7D;AAED,MAAM,WAAW,mBAAmB;IAClC,gEAAgE;IAChE,QAAQ,CAAC,GAAG,EAAE,UAAU,GAAG,IAAI,CAAC;IAChC;;;;OAIG;IACH,UAAU,CAAC,EAAE,EAAE,SAAS,GAAG,IAAI,CAAC;CACjC;AAED,wBAAgB,yBAAyB,CAAC,IAAI,EAAE,uBAAuB,GAAG,mBAAmB,CA4G5F"}