@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.
- package/dist/admin-blueprints-transport.d.ts.map +1 -1
- package/dist/admin-blueprints-transport.js +2 -1
- package/dist/admin-oauth-providers-transport.d.ts.map +1 -1
- package/dist/admin-oauth-providers-transport.js +7 -5
- package/dist/api-renders-routes.d.ts +85 -0
- package/dist/api-renders-routes.d.ts.map +1 -0
- package/dist/api-renders-routes.js +372 -0
- package/dist/build-mcp.d.ts +2 -2
- package/dist/build-mcp.d.ts.map +1 -1
- package/dist/build-mcp.js +64 -5
- package/dist/code-routes.d.ts +47 -0
- package/dist/code-routes.d.ts.map +1 -0
- package/dist/code-routes.js +81 -0
- package/dist/code-store-fs.js +2 -2
- package/dist/console-auth.d.ts +10 -10
- package/dist/console-auth.d.ts.map +1 -1
- package/dist/console-auth.js +5 -5
- package/dist/console-blueprint-routes.d.ts +71 -0
- package/dist/console-blueprint-routes.d.ts.map +1 -0
- package/dist/console-blueprint-routes.js +348 -0
- package/dist/console-chat-routes.d.ts +80 -0
- package/dist/console-chat-routes.d.ts.map +1 -0
- package/dist/console-chat-routes.js +204 -0
- package/dist/console-config-routes.d.ts +37 -0
- package/dist/console-config-routes.d.ts.map +1 -0
- package/dist/console-config-routes.js +91 -0
- package/dist/console-headers.d.ts +1 -1
- package/dist/console-info-routes.d.ts +84 -0
- package/dist/console-info-routes.d.ts.map +1 -0
- package/dist/console-info-routes.js +135 -0
- package/dist/console-keys-routes.d.ts +50 -0
- package/dist/console-keys-routes.d.ts.map +1 -0
- package/dist/console-keys-routes.js +222 -0
- package/dist/console-llm-keys-routes.d.ts +47 -0
- package/dist/console-llm-keys-routes.d.ts.map +1 -0
- package/dist/console-llm-keys-routes.js +443 -0
- package/dist/console-mcp-tools-routes.d.ts +41 -0
- package/dist/console-mcp-tools-routes.d.ts.map +1 -0
- package/dist/console-mcp-tools-routes.js +60 -0
- package/dist/console-registry-routes.d.ts +66 -0
- package/dist/console-registry-routes.d.ts.map +1 -0
- package/dist/console-registry-routes.js +276 -0
- package/dist/console-session-routes.d.ts +89 -0
- package/dist/console-session-routes.d.ts.map +1 -0
- package/dist/console-session-routes.js +385 -0
- package/dist/console-sessions-routes.d.ts +52 -0
- package/dist/console-sessions-routes.d.ts.map +1 -0
- package/dist/console-sessions-routes.js +106 -0
- package/dist/console-static-routes.d.ts +54 -0
- package/dist/console-static-routes.d.ts.map +1 -0
- package/dist/console-static-routes.js +190 -0
- package/dist/console-theme-routes.d.ts +3 -3
- package/dist/console-theme-routes.js +1 -1
- package/dist/console-timeline.d.ts +5 -5
- package/dist/console-timeline.d.ts.map +1 -1
- package/dist/console-timeline.js +27 -26
- package/dist/console-welcome.js +2 -2
- package/dist/email-login.d.ts.map +1 -1
- package/dist/email-login.js +2 -3
- package/dist/ggui-session-channel/action-ingress.d.ts +54 -0
- package/dist/ggui-session-channel/action-ingress.d.ts.map +1 -0
- package/dist/ggui-session-channel/action-ingress.js +228 -0
- package/dist/ggui-session-channel/channel-subscriptions.d.ts +97 -0
- package/dist/ggui-session-channel/channel-subscriptions.d.ts.map +1 -0
- package/dist/ggui-session-channel/channel-subscriptions.js +224 -0
- package/dist/ggui-session-channel/internal-types.d.ts +102 -0
- package/dist/ggui-session-channel/internal-types.d.ts.map +1 -0
- package/dist/ggui-session-channel/internal-types.js +6 -0
- package/dist/ggui-session-channel/outbound.d.ts +81 -0
- package/dist/ggui-session-channel/outbound.d.ts.map +1 -0
- package/dist/ggui-session-channel/outbound.js +174 -0
- package/dist/ggui-session-channel/socket-router.d.ts +38 -0
- package/dist/ggui-session-channel/socket-router.d.ts.map +1 -0
- package/dist/ggui-session-channel/socket-router.js +213 -0
- package/dist/ggui-session-channel/subscribe.d.ts +165 -0
- package/dist/ggui-session-channel/subscribe.d.ts.map +1 -0
- package/dist/ggui-session-channel/subscribe.js +370 -0
- package/dist/ggui-session-channel/subscriber-lifecycle.d.ts +40 -0
- package/dist/ggui-session-channel/subscriber-lifecycle.d.ts.map +1 -0
- package/dist/ggui-session-channel/subscriber-lifecycle.js +123 -0
- package/dist/ggui-session-channel.d.ts +425 -0
- package/dist/ggui-session-channel.d.ts.map +1 -0
- package/dist/ggui-session-channel.js +262 -0
- package/dist/health-routes.d.ts +76 -0
- package/dist/health-routes.d.ts.map +1 -0
- package/dist/health-routes.js +145 -0
- package/dist/index.d.ts +10 -11
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -9
- package/dist/instructions-presets.d.ts +3 -3
- package/dist/instructions-presets.js +24 -24
- package/dist/llm-backed-negotiator.d.ts +68 -67
- package/dist/llm-backed-negotiator.d.ts.map +1 -1
- package/dist/llm-backed-negotiator.js +82 -221
- package/dist/mcp-apps-outbound.d.ts +47 -48
- package/dist/mcp-apps-outbound.d.ts.map +1 -1
- package/dist/mcp-apps-outbound.js +154 -177
- package/dist/mcp-endpoint-routes.d.ts +88 -0
- package/dist/mcp-endpoint-routes.d.ts.map +1 -0
- package/dist/mcp-endpoint-routes.js +359 -0
- package/dist/mcp-mounts.d.ts +2 -76
- package/dist/mcp-mounts.d.ts.map +1 -1
- package/dist/mcp-mounts.js +0 -76
- package/dist/oauth-as-routes.d.ts +60 -0
- package/dist/oauth-as-routes.d.ts.map +1 -0
- package/dist/oauth-as-routes.js +82 -0
- package/dist/oauth-clients-routes.d.ts +39 -0
- package/dist/oauth-clients-routes.d.ts.map +1 -0
- package/dist/oauth-clients-routes.js +87 -0
- package/dist/oauth-login-types.d.ts +1 -20
- package/dist/oauth-login-types.d.ts.map +1 -1
- package/dist/oauth-login-types.js +30 -7
- package/dist/oauth-login.d.ts.map +1 -1
- package/dist/oauth-login.js +3 -2
- package/dist/oauth-providers-store.d.ts.map +1 -1
- package/dist/oauth-providers-store.js +5 -5
- package/dist/oauth.d.ts +9 -8
- package/dist/oauth.d.ts.map +1 -1
- package/dist/oauth.js +41 -19
- package/dist/pairing-transport.d.ts.map +1 -1
- package/dist/pairing-transport.js +2 -1
- package/dist/request-context.d.ts +2 -2
- package/dist/request-context.js +2 -2
- package/dist/reserved-validators.d.ts.map +1 -1
- package/dist/reserved-validators.js +9 -1
- package/dist/route-param.d.ts +9 -0
- package/dist/route-param.d.ts.map +1 -0
- package/dist/route-param.js +10 -0
- package/dist/runtime-bundle-route.d.ts +43 -0
- package/dist/runtime-bundle-route.d.ts.map +1 -0
- package/dist/runtime-bundle-route.js +80 -0
- package/dist/schema-compat.d.ts +64 -62
- package/dist/schema-compat.d.ts.map +1 -1
- package/dist/schema-compat.js +23 -51
- package/dist/server.d.ts +179 -193
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +644 -3759
- package/dist/storage.d.ts +5 -5
- package/dist/storage.d.ts.map +1 -1
- package/dist/storage.js +5 -5
- package/dist/thread-transport.d.ts.map +1 -1
- package/dist/thread-transport.js +4 -3
- package/dist/user-session-auth.d.ts +7 -21
- package/dist/user-session-auth.d.ts.map +1 -1
- package/dist/user-session-auth.js +7 -28
- package/package.json +16 -15
- package/dist/mcp-apps-inbound.d.ts +0 -86
- package/dist/mcp-apps-inbound.d.ts.map +0 -1
- package/dist/mcp-apps-inbound.js +0 -283
- package/dist/render-channel.d.ts +0 -694
- package/dist/render-channel.d.ts.map +0 -1
- 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"}
|