@ggui-ai/mcp-server 0.2.0-alpha.4 → 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.
- 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 +1 -1
- package/dist/build-mcp.d.ts.map +1 -1
- package/dist/build-mcp.js +39 -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 +182 -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,385 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Console render-viewer session routes (cookie mint + resource +
|
|
3
|
+
* meta + observation).
|
|
4
|
+
*
|
|
5
|
+
* POST /ggui/console/session-cookie
|
|
6
|
+
* — resolve shortCode → render and mint the same-origin HTTP-only
|
|
7
|
+
* cookie the viewer authenticates to the live channel with.
|
|
8
|
+
*
|
|
9
|
+
* GET /ggui/console/session-resource?session=<sessionId>
|
|
10
|
+
* — production thin-shell HTML, wrapped as a ResourceContents
|
|
11
|
+
* blob (`text` IS `GGUI_RENDER_SHELL_HTML`, byte-identical to
|
|
12
|
+
* what Claude Desktop fetches via MCP `resources/read
|
|
13
|
+
* ui://ggui/render`). NO inlined bootstrap — console fetches
|
|
14
|
+
* the bootstrap separately (meta route below) and replies to
|
|
15
|
+
* the iframe's `ui/initialize` postMessage with it.
|
|
16
|
+
*
|
|
17
|
+
* GET /ggui/console/sessions/:sessionId/meta
|
|
18
|
+
* — slice-envelope JSON (`{ "ai.ggui/render": {...} }`, the same
|
|
19
|
+
* shape as the wire `_meta`). The console forwards this to the
|
|
20
|
+
* iframe as the `_meta` slice of a spec-canonical
|
|
21
|
+
* `ui/notifications/tool-result` notification (per
|
|
22
|
+
* `docs/protocol/extensions/ai.ggui-meta.md`). Requires
|
|
23
|
+
* `mcpApps: true` (mintBootstrap presence) — 503 otherwise.
|
|
24
|
+
*
|
|
25
|
+
* GET /ggui/console/session?session=<sessionId>
|
|
26
|
+
* — `{render, eventSequence}` JSON. Console-only observation
|
|
27
|
+
* surface for `<GguiSessionViewer>` to mount `<RenderInspector>`.
|
|
28
|
+
*
|
|
29
|
+
* Named parties (all routes):
|
|
30
|
+
* - console (SPA caller) — holds the same-origin cookie.
|
|
31
|
+
* - mcp-server (these handlers) — gates auth + scope; mints the
|
|
32
|
+
* bootstrap on the meta route.
|
|
33
|
+
* - host wrapper (bootstrap forwarder; `<AppRenderer>` on web,
|
|
34
|
+
* `<McpAppIframe>` on RN) — receives the bootstrap JSON and
|
|
35
|
+
* forwards it as a `ui/notifications/tool-result` `_meta` slice.
|
|
36
|
+
* - renderer bundle (inside the iframe) — runs the same boot
|
|
37
|
+
* code path as production; reads the single `ai.ggui/render`
|
|
38
|
+
* slice meta.
|
|
39
|
+
*
|
|
40
|
+
* Auth + scope obligations (uniform):
|
|
41
|
+
* - Cookie-auth via `readDevtoolCookieFromHeaders` +
|
|
42
|
+
* `verifyDevtoolCookie`. Invalid / missing → 401.
|
|
43
|
+
* - Scope: `cookie.sessionId` MUST equal the requested session.
|
|
44
|
+
* Cross-render access with a valid cookie → 403.
|
|
45
|
+
* - GguiSession existence + appId match: 404 / 403 respectively.
|
|
46
|
+
*
|
|
47
|
+
* The mount RETURNS the `GguiSessionChannelCookieAuth` binding the
|
|
48
|
+
* composer threads into `createGguiSessionChannelServer`, so the
|
|
49
|
+
* cookie minted here authenticates the live-channel WS upgrade.
|
|
50
|
+
*/
|
|
51
|
+
import { deriveContractBundle } from "@ggui-ai/mcp-server-handlers/renders";
|
|
52
|
+
import { GGUI_RENDER_RESOURCE_MIME, GGUI_RENDER_RESOURCE_URI, MCP_APP_AI_GGUI_RENDER_META_KEY, } from "@ggui-ai/protocol/integrations/mcp-apps";
|
|
53
|
+
import { CONSOLE_COOKIE_NAME, mintDevtoolCookie, readDevtoolCookieFromHeaders, verifyDevtoolCookie, } from "./console-auth.js";
|
|
54
|
+
import { applyDevtoolSecurityHeaders } from "./console-headers.js";
|
|
55
|
+
import { GGUI_RENDER_SHELL_HTML } from "./mcp-apps-outbound.js";
|
|
56
|
+
import { singleParam } from "./route-param.js";
|
|
57
|
+
/**
|
|
58
|
+
* Mount the console session routes onto the express app and return
|
|
59
|
+
* the cookie-auth binding for the live channel.
|
|
60
|
+
*/
|
|
61
|
+
export function mountConsoleSessionRoutes(opts) {
|
|
62
|
+
const { app, secret, shortCodeIndex, cookieTtlSec, cookieSecure, renderStore, mintBootstrap, runtimeBootstrapUrl, codeStore, publicBaseUrl, logger, } = opts;
|
|
63
|
+
const cookieLogger = logger.child({
|
|
64
|
+
component: "console-cookie",
|
|
65
|
+
});
|
|
66
|
+
app.post("/ggui/console/session-cookie", async (req, res) => {
|
|
67
|
+
applyDevtoolSecurityHeaders(res);
|
|
68
|
+
const body = (req.body ?? {});
|
|
69
|
+
if (typeof body.shortCode !== "string" || body.shortCode.length === 0) {
|
|
70
|
+
res.status(400).json({
|
|
71
|
+
error: "invalid_request",
|
|
72
|
+
message: "`shortCode` (string) is required",
|
|
73
|
+
});
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
let binding;
|
|
77
|
+
try {
|
|
78
|
+
binding = await shortCodeIndex.lookup(body.shortCode);
|
|
79
|
+
}
|
|
80
|
+
catch (err) {
|
|
81
|
+
cookieLogger.error("short_code_lookup_failed", {
|
|
82
|
+
error: String(err),
|
|
83
|
+
});
|
|
84
|
+
res.status(500).json({ error: "internal_error" });
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
if (!binding) {
|
|
88
|
+
res.status(404).json({
|
|
89
|
+
error: "unknown_short_code",
|
|
90
|
+
message: "Short-code does not resolve to any render on this server",
|
|
91
|
+
});
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
const mint = mintDevtoolCookie({
|
|
95
|
+
sessionId: binding.sessionId,
|
|
96
|
+
appId: binding.appId,
|
|
97
|
+
secret,
|
|
98
|
+
...(cookieTtlSec !== undefined ? { ttlSec: cookieTtlSec } : {}),
|
|
99
|
+
secure: cookieSecure,
|
|
100
|
+
});
|
|
101
|
+
res.setHeader("Set-Cookie", mint.setCookieHeader);
|
|
102
|
+
res.json({
|
|
103
|
+
sessionId: mint.sessionId,
|
|
104
|
+
appId: mint.appId,
|
|
105
|
+
expiresAt: mint.expiresAt,
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
/**
|
|
109
|
+
* Shared auth + scope gate for the console render routes.
|
|
110
|
+
* Returns the verified `(sessionId, appId)` pair on success or
|
|
111
|
+
* `null` after writing an HTTP error response on failure.
|
|
112
|
+
*
|
|
113
|
+
* Internal — closure-scoped to the route block; not exported.
|
|
114
|
+
*/
|
|
115
|
+
const gateDevtoolRenderRequest = async (req, res, explicitSessionId) => {
|
|
116
|
+
const sessionIdRaw = explicitSessionId !== undefined ? explicitSessionId : req.query["session"];
|
|
117
|
+
if (typeof sessionIdRaw !== "string" || sessionIdRaw.length === 0) {
|
|
118
|
+
res.status(400).json({
|
|
119
|
+
error: "invalid_request",
|
|
120
|
+
message: "`render` query parameter (or :sessionId path parameter on the meta route) is required",
|
|
121
|
+
});
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
const rawCookie = readDevtoolCookieFromHeaders(req.headers);
|
|
125
|
+
if (!rawCookie) {
|
|
126
|
+
res.status(401).json({
|
|
127
|
+
error: "missing_cookie",
|
|
128
|
+
message: `${CONSOLE_COOKIE_NAME} cookie required (mint via POST /ggui/console/session-cookie first)`,
|
|
129
|
+
});
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
const claims = verifyDevtoolCookie(rawCookie, secret);
|
|
133
|
+
if (!claims) {
|
|
134
|
+
res.status(401).json({
|
|
135
|
+
error: "invalid_cookie",
|
|
136
|
+
message: "Console cookie is invalid, expired, or for another server",
|
|
137
|
+
});
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
if (claims.sessionId !== sessionIdRaw) {
|
|
141
|
+
res.status(403).json({
|
|
142
|
+
error: "cookie_session_mismatch",
|
|
143
|
+
message: `Console cookie is bound to render '${claims.sessionId}' but request targets '${sessionIdRaw}'`,
|
|
144
|
+
});
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
// GguiSession existence + appId match — even on the static-shell
|
|
148
|
+
// route we honestly answer 404 instead of leaking an HTML blob
|
|
149
|
+
// for a render the server doesn't know about.
|
|
150
|
+
let render = null;
|
|
151
|
+
if (renderStore) {
|
|
152
|
+
try {
|
|
153
|
+
render = await renderStore.get(claims.sessionId);
|
|
154
|
+
}
|
|
155
|
+
catch (err) {
|
|
156
|
+
cookieLogger.error("render_resource_store_failed", {
|
|
157
|
+
error: String(err),
|
|
158
|
+
sessionId: claims.sessionId,
|
|
159
|
+
});
|
|
160
|
+
res.status(500).json({ error: "internal_error" });
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
if (!render) {
|
|
165
|
+
res.status(404).json({
|
|
166
|
+
error: "session_not_found",
|
|
167
|
+
message: `GguiSession '${claims.sessionId}' is not on this server`,
|
|
168
|
+
});
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
if (render.appId !== claims.appId) {
|
|
172
|
+
res.status(403).json({
|
|
173
|
+
error: "app_mismatch",
|
|
174
|
+
message: `Cookie bound to app '${claims.appId}' but render is on app '${render.appId}'`,
|
|
175
|
+
});
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
return { sessionId: claims.sessionId, appId: claims.appId };
|
|
179
|
+
};
|
|
180
|
+
// GET /ggui/console/session-resource?session=<sessionId>
|
|
181
|
+
// → production thin-shell HTML, wrapped as a ResourceContents
|
|
182
|
+
// blob. NO inlined bootstrap — console fetches the bootstrap
|
|
183
|
+
// separately (route below) and replies to the iframe's
|
|
184
|
+
// `ui/initialize` postMessage with it.
|
|
185
|
+
app.get("/ggui/console/session-resource", async (req, res) => {
|
|
186
|
+
applyDevtoolSecurityHeaders(res);
|
|
187
|
+
res.setHeader("Content-Type", "application/json; charset=utf-8");
|
|
188
|
+
const verified = await gateDevtoolRenderRequest(req, res);
|
|
189
|
+
if (!verified)
|
|
190
|
+
return;
|
|
191
|
+
res.status(200).json({
|
|
192
|
+
contents: [
|
|
193
|
+
{
|
|
194
|
+
uri: GGUI_RENDER_RESOURCE_URI,
|
|
195
|
+
mimeType: GGUI_RENDER_RESOURCE_MIME,
|
|
196
|
+
text: GGUI_RENDER_SHELL_HTML,
|
|
197
|
+
},
|
|
198
|
+
],
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
// GET /ggui/console/sessions/:sessionId/meta
|
|
202
|
+
// → slice-envelope JSON (`{ "ai.ggui/render": {...} }`, the same
|
|
203
|
+
// shape as the wire `_meta`). Required when the console is
|
|
204
|
+
// hosting the renderer in a srcdoc iframe and needs to feed
|
|
205
|
+
// the iframe a meta payload via `ui/initialize`. `mcpApps:
|
|
206
|
+
// true` is required (mintWsToken/mintBootstrap presence) —
|
|
207
|
+
// 503 otherwise.
|
|
208
|
+
app.get("/ggui/console/sessions/:sessionId/meta", async (req, res) => {
|
|
209
|
+
applyDevtoolSecurityHeaders(res);
|
|
210
|
+
res.setHeader("Content-Type", "application/json; charset=utf-8");
|
|
211
|
+
const sessionIdFromPath = singleParam(req.params["sessionId"]);
|
|
212
|
+
const verified = await gateDevtoolRenderRequest(req, res, sessionIdFromPath);
|
|
213
|
+
if (!verified)
|
|
214
|
+
return;
|
|
215
|
+
if (!mintBootstrap) {
|
|
216
|
+
res.status(503).json({
|
|
217
|
+
error: "mcp_apps_disabled",
|
|
218
|
+
message: "renders/:sessionId/meta requires mcpApps: true on the server so the renderer can receive a valid WS auth token. Enable `mcpApps` on createGguiServer() and retry.",
|
|
219
|
+
});
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
const minted = mintBootstrap(verified.sessionId, verified.appId);
|
|
223
|
+
// Console/srcdoc absolute-URL fix: `<McpAppIframe>`
|
|
224
|
+
// mounts the resource via `srcdoc`, so the iframe's URL
|
|
225
|
+
// is `about:srcdoc` and any relative URL would resolve
|
|
226
|
+
// against that, not the dev server. Rewrite the
|
|
227
|
+
// same-origin `runtimeUrl` to absolute based on the
|
|
228
|
+
// request host. The `wsUrl` minted at server-boot
|
|
229
|
+
// defaults to `ws://localhost/ws` — also rewrite that
|
|
230
|
+
// to the request-host's actual host:port when its
|
|
231
|
+
// hostname is `localhost`, so the iframe's WebSocket
|
|
232
|
+
// open lands on the same listener. Operators who
|
|
233
|
+
// configured a CDN / external `wsUrl` / `runtime.url`
|
|
234
|
+
// are passed through unchanged.
|
|
235
|
+
const requestHost = req.get("host") ?? "";
|
|
236
|
+
const absoluteRendererUrl = /^https?:\/\//i.test(runtimeBootstrapUrl)
|
|
237
|
+
? runtimeBootstrapUrl
|
|
238
|
+
: `${req.protocol}://${requestHost}${runtimeBootstrapUrl}`;
|
|
239
|
+
let resolvedWsUrl = minted.wsUrl;
|
|
240
|
+
try {
|
|
241
|
+
const wsParsed = new URL(minted.wsUrl);
|
|
242
|
+
if ((wsParsed.hostname === "localhost" || wsParsed.hostname === "127.0.0.1") &&
|
|
243
|
+
requestHost.length > 0) {
|
|
244
|
+
const wsScheme = req.protocol === "https" ? "wss" : "ws";
|
|
245
|
+
resolvedWsUrl = `${wsScheme}://${requestHost}${wsParsed.pathname}${wsParsed.search}`;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
catch {
|
|
249
|
+
// Malformed `wsUrl` — leave it as-is so the renderer
|
|
250
|
+
// surfaces the failure through its own bootstrap-failed
|
|
251
|
+
// envelope rather than us silently rewriting a string
|
|
252
|
+
// we don't understand.
|
|
253
|
+
}
|
|
254
|
+
// Content-addressable contract-validator bundle (#109) for
|
|
255
|
+
// the active render. The renderer iframe's strict CSP
|
|
256
|
+
// forbids the `new Function` codegen `ajv.compile()` needs,
|
|
257
|
+
// so the server compiles + writes the bundle to its
|
|
258
|
+
// CodeStore at render time and threads the URL here. The
|
|
259
|
+
// iframe fetches the URL + dynamic-imports to resolve
|
|
260
|
+
// validators. Best-effort: a missing bundle degrades to no
|
|
261
|
+
// client-side validation; server-side `assertActionContract`
|
|
262
|
+
// remains authoritative.
|
|
263
|
+
let renderContractHash;
|
|
264
|
+
let renderValidatorsUrl;
|
|
265
|
+
if (renderStore && codeStore) {
|
|
266
|
+
try {
|
|
267
|
+
const stored = await renderStore.get(verified.sessionId);
|
|
268
|
+
if (stored !== null &&
|
|
269
|
+
stored.render.type !== "mcpApps" &&
|
|
270
|
+
stored.render.type !== "system" &&
|
|
271
|
+
typeof stored.render.componentCode === "string" &&
|
|
272
|
+
stored.render.componentCode.length > 0) {
|
|
273
|
+
const bundle = await deriveContractBundle(stored.render);
|
|
274
|
+
if (bundle) {
|
|
275
|
+
await codeStore.put(bundle.contractHash, bundle.bundleSource);
|
|
276
|
+
renderContractHash = bundle.contractHash;
|
|
277
|
+
const baseForValidators = publicBaseUrl
|
|
278
|
+
? publicBaseUrl.replace(/\/$/, "")
|
|
279
|
+
: `${req.protocol}://${requestHost}`;
|
|
280
|
+
renderValidatorsUrl = `${baseForValidators}/contract/${bundle.contractHash}.js`;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
catch (err) {
|
|
285
|
+
cookieLogger.warn("render_meta_validators_failed", {
|
|
286
|
+
error: String(err),
|
|
287
|
+
sessionId: verified.sessionId,
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
// Slice-envelope response (Phase B: single ai.ggui/render
|
|
292
|
+
// slice) — same shape as the wire `_meta` and the inline
|
|
293
|
+
// `__GGUI_META__` global the `/r/<shortCode>` shell carries.
|
|
294
|
+
// GguiSessionViewer parses with `parseMcpAppAiGguiRenderMeta`.
|
|
295
|
+
const renderMeta = {
|
|
296
|
+
sessionId: verified.sessionId,
|
|
297
|
+
appId: verified.appId,
|
|
298
|
+
runtimeUrl: absoluteRendererUrl,
|
|
299
|
+
wsUrl: resolvedWsUrl,
|
|
300
|
+
wsToken: minted.token,
|
|
301
|
+
expiresAt: minted.expiresAt,
|
|
302
|
+
...(renderContractHash !== undefined && renderValidatorsUrl !== undefined
|
|
303
|
+
? {
|
|
304
|
+
contractHash: renderContractHash,
|
|
305
|
+
validatorsUrl: renderValidatorsUrl,
|
|
306
|
+
}
|
|
307
|
+
: {}),
|
|
308
|
+
};
|
|
309
|
+
res.status(200).json({
|
|
310
|
+
[MCP_APP_AI_GGUI_RENDER_META_KEY]: renderMeta,
|
|
311
|
+
});
|
|
312
|
+
});
|
|
313
|
+
// GET /ggui/console/session?session=<sessionId>
|
|
314
|
+
// → `{render, eventSequence}` JSON.
|
|
315
|
+
//
|
|
316
|
+
// Console-only observation surface for `<GguiSessionViewer>` to mount
|
|
317
|
+
// `<RenderInspector>`. The iframe owns the live WS subscription
|
|
318
|
+
// + the bootstrap token (single-use), so the OUTER console DOM
|
|
319
|
+
// has no live source for render data — without this endpoint
|
|
320
|
+
// the inspector can't render contract / test-action panels.
|
|
321
|
+
//
|
|
322
|
+
// Failure modes:
|
|
323
|
+
// - 401 missing/invalid cookie · 403 cross-render/app · 404
|
|
324
|
+
// unknown render · 500 store failure (all delegated to
|
|
325
|
+
// `gateDevtoolRenderRequest`).
|
|
326
|
+
// - 503 if `renderStore` is not wired (zero-config server).
|
|
327
|
+
//
|
|
328
|
+
// Shape note: Phase B collapsed the prior session-stack array to
|
|
329
|
+
// a single `GguiSession` row. The response now returns the resolved
|
|
330
|
+
// `GguiSession` directly; console narrows on `render.type` before
|
|
331
|
+
// passing into `<RenderInspector>` (which only accepts the
|
|
332
|
+
// ComponentGguiSession variant since the inspector reads actionSpec /
|
|
333
|
+
// streamSpec / propsSpec — fields McpAppsGguiSession doesn't carry).
|
|
334
|
+
app.get("/ggui/console/session", async (req, res) => {
|
|
335
|
+
applyDevtoolSecurityHeaders(res);
|
|
336
|
+
res.setHeader("Content-Type", "application/json; charset=utf-8");
|
|
337
|
+
const verified = await gateDevtoolRenderRequest(req, res);
|
|
338
|
+
if (!verified)
|
|
339
|
+
return;
|
|
340
|
+
if (!renderStore) {
|
|
341
|
+
res.status(503).json({
|
|
342
|
+
error: "session_store_unavailable",
|
|
343
|
+
message: "GguiSession observation requires renderChannel: true on the server so the render store is wired. Enable `renderChannel` on createGguiServer() and retry.",
|
|
344
|
+
});
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
let stored = null;
|
|
348
|
+
try {
|
|
349
|
+
stored = await renderStore.get(verified.sessionId);
|
|
350
|
+
}
|
|
351
|
+
catch (err) {
|
|
352
|
+
cookieLogger.error("render_store_failed", {
|
|
353
|
+
error: String(err),
|
|
354
|
+
sessionId: verified.sessionId,
|
|
355
|
+
});
|
|
356
|
+
res.status(500).json({ error: "internal_error" });
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
if (!stored) {
|
|
360
|
+
// Race: gate verified existence above but the render
|
|
361
|
+
// could expire between calls. Honest 404.
|
|
362
|
+
res.status(404).json({
|
|
363
|
+
error: "session_not_found",
|
|
364
|
+
message: `GguiSession '${verified.sessionId}' is not on this server`,
|
|
365
|
+
});
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
res.status(200).json({
|
|
369
|
+
render: stored.render,
|
|
370
|
+
eventSequence: stored.eventSequence,
|
|
371
|
+
});
|
|
372
|
+
});
|
|
373
|
+
// Reference the cookie name to keep the export alive for
|
|
374
|
+
// downstream consumers + lint. The name is the single source
|
|
375
|
+
// of truth; avoid duplicating the string anywhere.
|
|
376
|
+
cookieLogger.debug?.("console_cookie_ready", {
|
|
377
|
+
cookieName: CONSOLE_COOKIE_NAME,
|
|
378
|
+
});
|
|
379
|
+
// Cookie-auth binding for the render channel — the composer
|
|
380
|
+
// threads this into `createGguiSessionChannelServer`.
|
|
381
|
+
return {
|
|
382
|
+
readCookie: readDevtoolCookieFromHeaders,
|
|
383
|
+
verify: (cookieValue) => verifyDevtoolCookie(cookieValue, secret),
|
|
384
|
+
};
|
|
385
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Console active-render list route.
|
|
3
|
+
*
|
|
4
|
+
* GET /ggui/console/sessions?limit=<n> — active-render list for
|
|
5
|
+
* the console SPA's `/admin/sessions` page. Operator-facing "what's
|
|
6
|
+
* live right now?" surface, enriched with each render's current
|
|
7
|
+
* shortCode so rows can link through to `/s/<shortCode>` (the
|
|
8
|
+
* existing render viewer).
|
|
9
|
+
*
|
|
10
|
+
* Scope: active renders only. The `GguiSessionFilter.status`
|
|
11
|
+
* taxonomy ('active' | 'completed' | 'expired') requires the
|
|
12
|
+
* store's private `closed` bucket flag to disambiguate completed
|
|
13
|
+
* from expired — that flag isn't on the `GguiSession` protocol type,
|
|
14
|
+
* so exposing mixed-status listings honestly requires a seam
|
|
15
|
+
* extension we don't need for the "live right now" use case.
|
|
16
|
+
* Future slices (historical renders, closed-renders triage)
|
|
17
|
+
* can opt in via query-param.
|
|
18
|
+
*
|
|
19
|
+
* Sources:
|
|
20
|
+
* - `renderStore.list({ status: 'active', limit })` — single
|
|
21
|
+
* page, limit default 25, clamped to [1, 100].
|
|
22
|
+
* - `shortCodeIndex.findBySessionId(render.id)` — best-effort
|
|
23
|
+
* enrichment; absent shortCode is a valid row (displays
|
|
24
|
+
* without a click-through link).
|
|
25
|
+
*
|
|
26
|
+
* Sort: most-recent `lastActivityAt` first — matches operator
|
|
27
|
+
* intent "show me what I was just looking at."
|
|
28
|
+
*
|
|
29
|
+
* Zero-config shape: `{ sessions: [], total: 0 }` when no
|
|
30
|
+
* renderStore is wired (e.g. pure-MCP dev boot with neither
|
|
31
|
+
* renderChannel nor mcpApps enabled).
|
|
32
|
+
*/
|
|
33
|
+
import type { GguiSessionStore, ShortCodeIndex } from "@ggui-ai/mcp-server-core";
|
|
34
|
+
import type { Express } from "express";
|
|
35
|
+
import type { Logger } from "./logger.js";
|
|
36
|
+
interface MountOptions {
|
|
37
|
+
/** Express app to mount onto. */
|
|
38
|
+
readonly app: Express;
|
|
39
|
+
/** GguiSession store the list reads (absent = honest empty list). */
|
|
40
|
+
readonly renderStore?: GguiSessionStore;
|
|
41
|
+
/** shortCode index for best-effort row enrichment. */
|
|
42
|
+
readonly shortCodeIndex?: ShortCodeIndex;
|
|
43
|
+
/** Structured logger. */
|
|
44
|
+
readonly logger: Logger;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Mount `GET /ggui/console/sessions` onto the express app. Returns
|
|
48
|
+
* nothing — the route self-registers.
|
|
49
|
+
*/
|
|
50
|
+
export declare function mountConsoleSessionsRoutes(opts: MountOptions): void;
|
|
51
|
+
export {};
|
|
52
|
+
//# sourceMappingURL=console-sessions-routes.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"console-sessions-routes.d.ts","sourceRoot":"","sources":["../src/console-sessions-routes.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AAEH,OAAO,KAAK,EAAE,gBAAgB,EAAE,cAAc,EAAE,MAAM,0BAA0B,CAAC;AACjF,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,SAAS,CAAC;AAEvC,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,aAAa,CAAC;AAE1C,UAAU,YAAY;IACpB,iCAAiC;IACjC,QAAQ,CAAC,GAAG,EAAE,OAAO,CAAC;IACtB,qEAAqE;IACrE,QAAQ,CAAC,WAAW,CAAC,EAAE,gBAAgB,CAAC;IACxC,sDAAsD;IACtD,QAAQ,CAAC,cAAc,CAAC,EAAE,cAAc,CAAC;IACzC,yBAAyB;IACzB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;CACzB;AAED;;;GAGG;AACH,wBAAgB,0BAA0B,CAAC,IAAI,EAAE,YAAY,GAAG,IAAI,CA8EnE"}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Console active-render list route.
|
|
3
|
+
*
|
|
4
|
+
* GET /ggui/console/sessions?limit=<n> — active-render list for
|
|
5
|
+
* the console SPA's `/admin/sessions` page. Operator-facing "what's
|
|
6
|
+
* live right now?" surface, enriched with each render's current
|
|
7
|
+
* shortCode so rows can link through to `/s/<shortCode>` (the
|
|
8
|
+
* existing render viewer).
|
|
9
|
+
*
|
|
10
|
+
* Scope: active renders only. The `GguiSessionFilter.status`
|
|
11
|
+
* taxonomy ('active' | 'completed' | 'expired') requires the
|
|
12
|
+
* store's private `closed` bucket flag to disambiguate completed
|
|
13
|
+
* from expired — that flag isn't on the `GguiSession` protocol type,
|
|
14
|
+
* so exposing mixed-status listings honestly requires a seam
|
|
15
|
+
* extension we don't need for the "live right now" use case.
|
|
16
|
+
* Future slices (historical renders, closed-renders triage)
|
|
17
|
+
* can opt in via query-param.
|
|
18
|
+
*
|
|
19
|
+
* Sources:
|
|
20
|
+
* - `renderStore.list({ status: 'active', limit })` — single
|
|
21
|
+
* page, limit default 25, clamped to [1, 100].
|
|
22
|
+
* - `shortCodeIndex.findBySessionId(render.id)` — best-effort
|
|
23
|
+
* enrichment; absent shortCode is a valid row (displays
|
|
24
|
+
* without a click-through link).
|
|
25
|
+
*
|
|
26
|
+
* Sort: most-recent `lastActivityAt` first — matches operator
|
|
27
|
+
* intent "show me what I was just looking at."
|
|
28
|
+
*
|
|
29
|
+
* Zero-config shape: `{ sessions: [], total: 0 }` when no
|
|
30
|
+
* renderStore is wired (e.g. pure-MCP dev boot with neither
|
|
31
|
+
* renderChannel nor mcpApps enabled).
|
|
32
|
+
*/
|
|
33
|
+
import { applyDevtoolSecurityHeaders } from "./console-headers.js";
|
|
34
|
+
/**
|
|
35
|
+
* Mount `GET /ggui/console/sessions` onto the express app. Returns
|
|
36
|
+
* nothing — the route self-registers.
|
|
37
|
+
*/
|
|
38
|
+
export function mountConsoleSessionsRoutes(opts) {
|
|
39
|
+
const { app, renderStore, shortCodeIndex, logger } = opts;
|
|
40
|
+
app.get("/ggui/console/sessions", async (req, res) => {
|
|
41
|
+
applyDevtoolSecurityHeaders(res);
|
|
42
|
+
const limitRaw = req.query["limit"];
|
|
43
|
+
let limit = 25;
|
|
44
|
+
if (typeof limitRaw === "string") {
|
|
45
|
+
const parsed = Number.parseInt(limitRaw, 10);
|
|
46
|
+
if (Number.isFinite(parsed) && parsed > 0) {
|
|
47
|
+
limit = Math.min(100, parsed);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
if (!renderStore) {
|
|
51
|
+
res.json({ sessions: [], total: 0 });
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
try {
|
|
55
|
+
const renders = await renderStore.list({
|
|
56
|
+
status: "active",
|
|
57
|
+
limit,
|
|
58
|
+
});
|
|
59
|
+
const summaries = [];
|
|
60
|
+
for (const render of renders) {
|
|
61
|
+
let shortCode = null;
|
|
62
|
+
if (shortCodeIndex) {
|
|
63
|
+
try {
|
|
64
|
+
shortCode = await shortCodeIndex.findBySessionId(render.id);
|
|
65
|
+
}
|
|
66
|
+
catch (err) {
|
|
67
|
+
// Best-effort — the render row is still honest
|
|
68
|
+
// without a shortCode.
|
|
69
|
+
logger.warn("console_renders_shortcode_lookup_failed", {
|
|
70
|
+
sessionId: render.id,
|
|
71
|
+
error: String(err),
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
summaries.push({
|
|
76
|
+
sessionId: render.id,
|
|
77
|
+
...(shortCode ? { shortCode } : {}),
|
|
78
|
+
appId: render.appId,
|
|
79
|
+
lastActivityAt: render.lastActivityAt,
|
|
80
|
+
createdAt: render.createdAt,
|
|
81
|
+
status: "active",
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
// Most-recent activity first. Tiebreak on sessionId for
|
|
85
|
+
// stability when multiple rows share the same ms timestamp.
|
|
86
|
+
summaries.sort((a, b) => {
|
|
87
|
+
const byRecency = b.lastActivityAt - a.lastActivityAt;
|
|
88
|
+
if (byRecency !== 0)
|
|
89
|
+
return byRecency;
|
|
90
|
+
return a.sessionId.localeCompare(b.sessionId);
|
|
91
|
+
});
|
|
92
|
+
res.json({ sessions: summaries, total: summaries.length });
|
|
93
|
+
}
|
|
94
|
+
catch (err) {
|
|
95
|
+
logger.warn("console_renders_list_failed", {
|
|
96
|
+
error: String(err),
|
|
97
|
+
});
|
|
98
|
+
res.status(500).json({
|
|
99
|
+
error: "renders_unavailable",
|
|
100
|
+
message: err instanceof Error
|
|
101
|
+
? `GguiSession store failed to list — ${err.message}`
|
|
102
|
+
: `GguiSession store failed to list — ${String(err)}`,
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Console static-bundle + SPA serving routes.
|
|
3
|
+
*
|
|
4
|
+
* GET <consolePath> — mode-meta-injected `index.html`,
|
|
5
|
+
* welcome page, or onboarding redirect.
|
|
6
|
+
* <consolePath>/* (static) — `express.static` over the package's
|
|
7
|
+
* built `dist/` (HTML + JS + CSS).
|
|
8
|
+
* GET /admin/* + /devtools/* — admin-HTML gate (302 to
|
|
9
|
+
* `/admin-login?next=…` on miss).
|
|
10
|
+
* GET <consolePath>/* (SPA) — fallback to the rewritten
|
|
11
|
+
* `index.html` so the React router
|
|
12
|
+
* takes over for client-side routes.
|
|
13
|
+
*
|
|
14
|
+
* When the `distDir` doesn't exist on disk (e.g. operator forgot
|
|
15
|
+
* to run `pnpm --filter @ggui-ai/console build`), the static
|
|
16
|
+
* route is replaced with a 503 that points at the missing build —
|
|
17
|
+
* silent 404 would be mistaken for "console is broken" rather
|
|
18
|
+
* than "console wasn't built yet," which is a real debugging
|
|
19
|
+
* trap for self-hosted operators.
|
|
20
|
+
*/
|
|
21
|
+
import type { Express } from "express";
|
|
22
|
+
import express from "express";
|
|
23
|
+
import { type WelcomePageInputs } from "./console-welcome.js";
|
|
24
|
+
import type { Logger } from "./logger.js";
|
|
25
|
+
interface MountOptions {
|
|
26
|
+
/** Express app to mount onto. */
|
|
27
|
+
readonly app: Express;
|
|
28
|
+
/** Mount path for the console (default `/`). */
|
|
29
|
+
readonly consolePath: string;
|
|
30
|
+
/** Built console bundle directory on disk. */
|
|
31
|
+
readonly consoleDistDir: string;
|
|
32
|
+
/** Operator mode stamped into `<meta name="ggui-mode">`. */
|
|
33
|
+
readonly mode: "dev" | "prod";
|
|
34
|
+
/** Server identity name (welcome-page fallback title). */
|
|
35
|
+
readonly serverName: string;
|
|
36
|
+
/** Welcome-page inputs — enables the server-rendered landing. */
|
|
37
|
+
readonly welcomePage?: WelcomePageInputs;
|
|
38
|
+
/** Onboarding redirect resolver (runs before welcome/SPA index). */
|
|
39
|
+
readonly landingRedirect?: () => string | null;
|
|
40
|
+
/**
|
|
41
|
+
* Admin gate for the SPA's `/admin/*` + `/devtools/*` zones. `null`
|
|
42
|
+
* = console disabled / no admin token resolved → gate unmounted.
|
|
43
|
+
*/
|
|
44
|
+
readonly requestHasAdminAuth: ((req: express.Request) => boolean) | null;
|
|
45
|
+
/** Structured logger for the missing-dist boot warning. */
|
|
46
|
+
readonly logger: Logger;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Mount the console static + SPA routes onto the express app.
|
|
50
|
+
* Returns nothing — the routes self-register.
|
|
51
|
+
*/
|
|
52
|
+
export declare function mountConsoleStaticRoutes(opts: MountOptions): void;
|
|
53
|
+
export {};
|
|
54
|
+
//# sourceMappingURL=console-static-routes.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"console-static-routes.d.ts","sourceRoot":"","sources":["../src/console-static-routes.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAEH,OAAO,KAAK,EAAE,OAAO,EAAY,MAAM,SAAS,CAAC;AACjD,OAAO,OAAO,MAAM,SAAS,CAAC;AAI9B,OAAO,EAAqB,KAAK,iBAAiB,EAAE,MAAM,sBAAsB,CAAC;AACjF,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,aAAa,CAAC;AAE1C,UAAU,YAAY;IACpB,iCAAiC;IACjC,QAAQ,CAAC,GAAG,EAAE,OAAO,CAAC;IACtB,gDAAgD;IAChD,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;IAC7B,8CAA8C;IAC9C,QAAQ,CAAC,cAAc,EAAE,MAAM,CAAC;IAChC,4DAA4D;IAC5D,QAAQ,CAAC,IAAI,EAAE,KAAK,GAAG,MAAM,CAAC;IAC9B,0DAA0D;IAC1D,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,iEAAiE;IACjE,QAAQ,CAAC,WAAW,CAAC,EAAE,iBAAiB,CAAC;IACzC,oEAAoE;IACpE,QAAQ,CAAC,eAAe,CAAC,EAAE,MAAM,MAAM,GAAG,IAAI,CAAC;IAC/C;;;OAGG;IACH,QAAQ,CAAC,mBAAmB,EAAE,CAAC,CAAC,GAAG,EAAE,OAAO,CAAC,OAAO,KAAK,OAAO,CAAC,GAAG,IAAI,CAAC;IACzE,2DAA2D;IAC3D,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;CACzB;AAED;;;GAGG;AACH,wBAAgB,wBAAwB,CAAC,IAAI,EAAE,YAAY,GAAG,IAAI,CA8KjE"}
|