@casys/mcp-bridge 0.2.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/esm/_dnt.shims.d.ts +2 -0
- package/esm/_dnt.shims.d.ts.map +1 -0
- package/esm/_dnt.shims.js +57 -0
- package/esm/adapters/base-adapter.d.ts +25 -0
- package/esm/adapters/base-adapter.d.ts.map +1 -0
- package/esm/adapters/base-adapter.js +86 -0
- package/esm/adapters/line/adapter.d.ts +11 -0
- package/esm/adapters/line/adapter.d.ts.map +1 -0
- package/esm/adapters/line/adapter.js +10 -0
- package/esm/adapters/line/types.d.ts +25 -0
- package/esm/adapters/line/types.d.ts.map +1 -0
- package/esm/adapters/line/types.js +4 -0
- package/esm/adapters/telegram/adapter.d.ts +11 -0
- package/esm/adapters/telegram/adapter.d.ts.map +1 -0
- package/esm/adapters/telegram/adapter.js +10 -0
- package/esm/adapters/telegram/platform-adapter.d.ts +40 -0
- package/esm/adapters/telegram/platform-adapter.d.ts.map +1 -0
- package/esm/adapters/telegram/platform-adapter.js +214 -0
- package/esm/adapters/telegram/sdk-bridge.d.ts +8 -0
- package/esm/adapters/telegram/sdk-bridge.d.ts.map +1 -0
- package/esm/adapters/telegram/sdk-bridge.js +22 -0
- package/esm/adapters/telegram/types.d.ts +93 -0
- package/esm/adapters/telegram/types.d.ts.map +1 -0
- package/esm/adapters/telegram/types.js +6 -0
- package/esm/client/bridge.js +424 -0
- package/esm/core/adapter.d.ts +88 -0
- package/esm/core/adapter.d.ts.map +1 -0
- package/esm/core/adapter.js +10 -0
- package/esm/core/bridge-client.d.ts +77 -0
- package/esm/core/bridge-client.d.ts.map +1 -0
- package/esm/core/bridge-client.js +275 -0
- package/esm/core/message-router.d.ts +71 -0
- package/esm/core/message-router.d.ts.map +1 -0
- package/esm/core/message-router.js +187 -0
- package/esm/core/protocol.d.ts +116 -0
- package/esm/core/protocol.d.ts.map +1 -0
- package/esm/core/protocol.js +203 -0
- package/esm/core/resource-resolver.d.ts +27 -0
- package/esm/core/resource-resolver.d.ts.map +1 -0
- package/esm/core/resource-resolver.js +85 -0
- package/esm/core/transport.d.ts +46 -0
- package/esm/core/transport.d.ts.map +1 -0
- package/esm/core/transport.js +85 -0
- package/esm/core/types.d.ts +187 -0
- package/esm/core/types.d.ts.map +1 -0
- package/esm/core/types.js +35 -0
- package/esm/mod.d.ts +36 -0
- package/esm/mod.d.ts.map +1 -0
- package/esm/mod.js +33 -0
- package/esm/package.json +3 -0
- package/esm/resource-server/csp.d.ts +36 -0
- package/esm/resource-server/csp.d.ts.map +1 -0
- package/esm/resource-server/csp.js +36 -0
- package/esm/resource-server/injector.d.ts +18 -0
- package/esm/resource-server/injector.d.ts.map +1 -0
- package/esm/resource-server/injector.js +39 -0
- package/esm/resource-server/server.d.ts +107 -0
- package/esm/resource-server/server.d.ts.map +1 -0
- package/esm/resource-server/server.js +483 -0
- package/esm/resource-server/session.d.ts +60 -0
- package/esm/resource-server/session.d.ts.map +1 -0
- package/esm/resource-server/session.js +86 -0
- package/esm/resource-server/telegram-auth.d.ts +45 -0
- package/esm/resource-server/telegram-auth.d.ts.map +1 -0
- package/esm/resource-server/telegram-auth.js +161 -0
- package/package.json +31 -0
- package/script/_dnt.shims.d.ts +2 -0
- package/script/_dnt.shims.d.ts.map +1 -0
- package/script/_dnt.shims.js +60 -0
- package/script/adapters/base-adapter.d.ts +25 -0
- package/script/adapters/base-adapter.d.ts.map +1 -0
- package/script/adapters/base-adapter.js +113 -0
- package/script/adapters/line/adapter.d.ts +11 -0
- package/script/adapters/line/adapter.d.ts.map +1 -0
- package/script/adapters/line/adapter.js +14 -0
- package/script/adapters/line/types.d.ts +25 -0
- package/script/adapters/line/types.d.ts.map +1 -0
- package/script/adapters/line/types.js +5 -0
- package/script/adapters/telegram/adapter.d.ts +11 -0
- package/script/adapters/telegram/adapter.d.ts.map +1 -0
- package/script/adapters/telegram/adapter.js +14 -0
- package/script/adapters/telegram/platform-adapter.d.ts +40 -0
- package/script/adapters/telegram/platform-adapter.d.ts.map +1 -0
- package/script/adapters/telegram/platform-adapter.js +241 -0
- package/script/adapters/telegram/sdk-bridge.d.ts +8 -0
- package/script/adapters/telegram/sdk-bridge.d.ts.map +1 -0
- package/script/adapters/telegram/sdk-bridge.js +48 -0
- package/script/adapters/telegram/types.d.ts +93 -0
- package/script/adapters/telegram/types.d.ts.map +1 -0
- package/script/adapters/telegram/types.js +7 -0
- package/script/client/bridge.js +424 -0
- package/script/core/adapter.d.ts +88 -0
- package/script/core/adapter.d.ts.map +1 -0
- package/script/core/adapter.js +11 -0
- package/script/core/bridge-client.d.ts +77 -0
- package/script/core/bridge-client.d.ts.map +1 -0
- package/script/core/bridge-client.js +302 -0
- package/script/core/message-router.d.ts +71 -0
- package/script/core/message-router.d.ts.map +1 -0
- package/script/core/message-router.js +191 -0
- package/script/core/protocol.d.ts +116 -0
- package/script/core/protocol.d.ts.map +1 -0
- package/script/core/protocol.js +230 -0
- package/script/core/resource-resolver.d.ts +27 -0
- package/script/core/resource-resolver.d.ts.map +1 -0
- package/script/core/resource-resolver.js +89 -0
- package/script/core/transport.d.ts +46 -0
- package/script/core/transport.d.ts.map +1 -0
- package/script/core/transport.js +112 -0
- package/script/core/types.d.ts +187 -0
- package/script/core/types.d.ts.map +1 -0
- package/script/core/types.js +38 -0
- package/script/mod.d.ts +36 -0
- package/script/mod.d.ts.map +1 -0
- package/script/mod.js +76 -0
- package/script/package.json +3 -0
- package/script/resource-server/csp.d.ts +36 -0
- package/script/resource-server/csp.d.ts.map +1 -0
- package/script/resource-server/csp.js +39 -0
- package/script/resource-server/injector.d.ts +18 -0
- package/script/resource-server/injector.d.ts.map +1 -0
- package/script/resource-server/injector.js +42 -0
- package/script/resource-server/server.d.ts +107 -0
- package/script/resource-server/server.d.ts.map +1 -0
- package/script/resource-server/server.js +487 -0
- package/script/resource-server/session.d.ts +60 -0
- package/script/resource-server/session.d.ts.map +1 -0
- package/script/resource-server/session.js +90 -0
- package/script/resource-server/telegram-auth.d.ts +45 -0
- package/script/resource-server/telegram-auth.d.ts.map +1 -0
- package/script/resource-server/telegram-auth.js +164 -0
|
@@ -0,0 +1,483 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP resource server for MCP Apps Bridge.
|
|
3
|
+
*
|
|
4
|
+
* Serves `ui://` resources as HTTP pages, injects bridge.js, sets CSP
|
|
5
|
+
* headers, and provides a WebSocket endpoint for bidirectional JSON-RPC
|
|
6
|
+
* communication between the BridgeClient (in the webview) and the MCP server.
|
|
7
|
+
*
|
|
8
|
+
* Endpoints:
|
|
9
|
+
* - `GET /app/<server>/<path>` — Serve MCP App HTML with injected bridge.js
|
|
10
|
+
* - `GET /bridge.js?platform=<p>&session=<s>` — Serve the bridge client script
|
|
11
|
+
* - `GET /health` — Health check
|
|
12
|
+
* - `WS /bridge?session=<id>` — WebSocket for JSON-RPC messaging
|
|
13
|
+
*
|
|
14
|
+
* Uses Deno.serve() for the HTTP server.
|
|
15
|
+
*/
|
|
16
|
+
import { isJsonRpcMessage } from "../core/protocol.js";
|
|
17
|
+
import { buildCspHeader } from "./csp.js";
|
|
18
|
+
import { injectBridgeScript } from "./injector.js";
|
|
19
|
+
import { SessionStore } from "./session.js";
|
|
20
|
+
import { validateTelegramInitData } from "./telegram-auth.js";
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
// MIME types
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
const MIME_MAP = {
|
|
25
|
+
".html": "text/html; charset=utf-8",
|
|
26
|
+
".js": "application/javascript; charset=utf-8",
|
|
27
|
+
".mjs": "application/javascript; charset=utf-8",
|
|
28
|
+
".css": "text/css; charset=utf-8",
|
|
29
|
+
".json": "application/json; charset=utf-8",
|
|
30
|
+
".png": "image/png",
|
|
31
|
+
".jpg": "image/jpeg",
|
|
32
|
+
".jpeg": "image/jpeg",
|
|
33
|
+
".gif": "image/gif",
|
|
34
|
+
".svg": "image/svg+xml",
|
|
35
|
+
".ico": "image/x-icon",
|
|
36
|
+
".woff": "font/woff",
|
|
37
|
+
".woff2": "font/woff2",
|
|
38
|
+
".ttf": "font/ttf",
|
|
39
|
+
};
|
|
40
|
+
function mimeType(path) {
|
|
41
|
+
const ext = path.slice(path.lastIndexOf("."));
|
|
42
|
+
return MIME_MAP[ext] ?? "application/octet-stream";
|
|
43
|
+
}
|
|
44
|
+
/** Normalize a path by resolving `.` and `..` segments without filesystem access. */
|
|
45
|
+
function normalizePath(p) {
|
|
46
|
+
const parts = p.split("/");
|
|
47
|
+
const result = [];
|
|
48
|
+
for (const part of parts) {
|
|
49
|
+
if (part === "..") {
|
|
50
|
+
result.pop();
|
|
51
|
+
}
|
|
52
|
+
else if (part !== "." && part !== "") {
|
|
53
|
+
result.push(part);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return (p.startsWith("/") ? "/" : "") + result.join("/");
|
|
57
|
+
}
|
|
58
|
+
/** Normalize a directory path, ensuring it ends with `/`. */
|
|
59
|
+
function normalizeDir(p) {
|
|
60
|
+
const n = normalizePath(p);
|
|
61
|
+
return n.endsWith("/") ? n : n + "/";
|
|
62
|
+
}
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
// Start server
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
/**
|
|
67
|
+
* Start the resource server.
|
|
68
|
+
*
|
|
69
|
+
* @returns A running ResourceServer with baseUrl and stop() method.
|
|
70
|
+
*/
|
|
71
|
+
export function startResourceServer(config) {
|
|
72
|
+
// -----------------------------------------------------------------------
|
|
73
|
+
// Fail-fast: telegram requires botToken
|
|
74
|
+
// -----------------------------------------------------------------------
|
|
75
|
+
if (config.platform === "telegram" && !config.telegramBotToken) {
|
|
76
|
+
throw new Error("[ResourceServer] telegramBotToken is required when platform is 'telegram'. " +
|
|
77
|
+
"Provide the Telegram Bot token for initData HMAC-SHA256 validation.");
|
|
78
|
+
}
|
|
79
|
+
const requiresAuth = !!config.telegramBotToken;
|
|
80
|
+
const port = config.options?.resourceServerPort ?? 0;
|
|
81
|
+
const allowedOrigins = config.options?.allowedOrigins ?? ["*"];
|
|
82
|
+
const debug = config.options?.debug ?? false;
|
|
83
|
+
const MAX_SESSIONS = 10_000;
|
|
84
|
+
const sessions = new SessionStore();
|
|
85
|
+
const wsConnections = new Map();
|
|
86
|
+
// Tool result store: ref -> data (auto-expires after 5 min)
|
|
87
|
+
const toolResultStore = new Map();
|
|
88
|
+
const toolResultTimers = new Map();
|
|
89
|
+
const TOOL_RESULT_TTL = 5 * 60 * 1000;
|
|
90
|
+
function storeToolResult(result) {
|
|
91
|
+
const ref = generateRef();
|
|
92
|
+
toolResultStore.set(ref, result);
|
|
93
|
+
const timer = setTimeout(() => {
|
|
94
|
+
toolResultStore.delete(ref);
|
|
95
|
+
toolResultTimers.delete(ref);
|
|
96
|
+
}, TOOL_RESULT_TTL);
|
|
97
|
+
toolResultTimers.set(ref, timer);
|
|
98
|
+
return ref;
|
|
99
|
+
}
|
|
100
|
+
function consumeToolResult(ref) {
|
|
101
|
+
const result = toolResultStore.get(ref);
|
|
102
|
+
if (result) {
|
|
103
|
+
toolResultStore.delete(ref);
|
|
104
|
+
const timer = toolResultTimers.get(ref);
|
|
105
|
+
if (timer) {
|
|
106
|
+
clearTimeout(timer);
|
|
107
|
+
toolResultTimers.delete(ref);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return result;
|
|
111
|
+
}
|
|
112
|
+
// Periodic session cleanup + stale WebSocket eviction
|
|
113
|
+
const cleanupInterval = setInterval(() => {
|
|
114
|
+
const removed = sessions.cleanup();
|
|
115
|
+
if (removed > 0) {
|
|
116
|
+
log("Cleaned up", removed, "expired session(s)");
|
|
117
|
+
}
|
|
118
|
+
// Close WebSocket connections whose session has expired
|
|
119
|
+
for (const [sessionId, ws] of wsConnections) {
|
|
120
|
+
if (!sessions.get(sessionId)) {
|
|
121
|
+
log("Closing stale WebSocket:", sessionId);
|
|
122
|
+
try {
|
|
123
|
+
ws.close(4002, "Session expired");
|
|
124
|
+
}
|
|
125
|
+
catch { /* already closed */ }
|
|
126
|
+
wsConnections.delete(sessionId);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}, 60_000);
|
|
130
|
+
function log(...args) {
|
|
131
|
+
if (debug) {
|
|
132
|
+
console.log("[ResourceServer]", ...args);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
function corsHeaders(request) {
|
|
136
|
+
let origin;
|
|
137
|
+
if (allowedOrigins.includes("*")) {
|
|
138
|
+
origin = "*";
|
|
139
|
+
}
|
|
140
|
+
else if (request) {
|
|
141
|
+
// Reflect the request origin if it's in the allowlist (HTTP spec: only one origin allowed)
|
|
142
|
+
const reqOrigin = request.headers.get("origin") ?? "";
|
|
143
|
+
origin = allowedOrigins.includes(reqOrigin) ? reqOrigin : allowedOrigins[0];
|
|
144
|
+
}
|
|
145
|
+
else {
|
|
146
|
+
origin = allowedOrigins[0];
|
|
147
|
+
}
|
|
148
|
+
return {
|
|
149
|
+
"Access-Control-Allow-Origin": origin,
|
|
150
|
+
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
|
|
151
|
+
"Access-Control-Allow-Headers": "Content-Type",
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
async function handleRequest(request) {
|
|
155
|
+
const url = new URL(request.url);
|
|
156
|
+
const path = url.pathname;
|
|
157
|
+
log(request.method, path);
|
|
158
|
+
// CORS preflight
|
|
159
|
+
if (request.method === "OPTIONS") {
|
|
160
|
+
return new Response(null, { status: 204, headers: corsHeaders(request) });
|
|
161
|
+
}
|
|
162
|
+
// Health check
|
|
163
|
+
if (path === "/health") {
|
|
164
|
+
return Response.json({ status: "ok", sessions: sessions.size }, { headers: corsHeaders(request) });
|
|
165
|
+
}
|
|
166
|
+
// Serve bridge.js client script
|
|
167
|
+
if (path === "/bridge.js") {
|
|
168
|
+
return await serveBridgeScript(corsHeaders(request));
|
|
169
|
+
}
|
|
170
|
+
// Session creation (rate-limited to prevent memory exhaustion DoS)
|
|
171
|
+
if (path === "/session" && request.method === "POST") {
|
|
172
|
+
if (sessions.size >= MAX_SESSIONS) {
|
|
173
|
+
log("Session creation rejected: max sessions reached (", MAX_SESSIONS, ")");
|
|
174
|
+
return new Response("Too many active sessions", { status: 429, headers: corsHeaders(request) });
|
|
175
|
+
}
|
|
176
|
+
const session = sessions.create(config.platform);
|
|
177
|
+
log("Session created:", session.id);
|
|
178
|
+
return Response.json({ sessionId: session.id }, { headers: corsHeaders(request) });
|
|
179
|
+
}
|
|
180
|
+
// WebSocket bridge endpoint
|
|
181
|
+
if (path === "/bridge") {
|
|
182
|
+
const sessionId = url.searchParams.get("session");
|
|
183
|
+
if (!sessionId) {
|
|
184
|
+
return new Response("Missing session parameter", { status: 400 });
|
|
185
|
+
}
|
|
186
|
+
// Session must exist (created via POST /session or HTML page load)
|
|
187
|
+
const session = sessions.get(sessionId);
|
|
188
|
+
if (!session) {
|
|
189
|
+
return new Response("Unknown or expired session. Create one via POST /session first.", { status: 403 });
|
|
190
|
+
}
|
|
191
|
+
const upgrade = request.headers.get("upgrade")?.toLowerCase();
|
|
192
|
+
if (upgrade !== "websocket") {
|
|
193
|
+
return new Response("Expected WebSocket upgrade", { status: 426 });
|
|
194
|
+
}
|
|
195
|
+
// deno-lint-ignore no-explicit-any
|
|
196
|
+
const { socket, response } = Deno.upgradeWebSocket(request);
|
|
197
|
+
socket.onopen = () => {
|
|
198
|
+
wsConnections.set(sessionId, socket);
|
|
199
|
+
sessions.touch(sessionId);
|
|
200
|
+
log("WebSocket connected:", sessionId);
|
|
201
|
+
// Flush pending notifications immediately — bridge.js queues
|
|
202
|
+
// them until the app has called ui/initialize, then replays.
|
|
203
|
+
if (session.pendingNotifications && session.pendingNotifications.length > 0) {
|
|
204
|
+
const pending = session.pendingNotifications;
|
|
205
|
+
session.pendingNotifications = undefined;
|
|
206
|
+
for (const notification of pending) {
|
|
207
|
+
if (socket.readyState === 1 /* OPEN */) {
|
|
208
|
+
const payload = JSON.stringify(notification);
|
|
209
|
+
log("Flushing pending notification:", notification.method);
|
|
210
|
+
socket.send(payload);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
};
|
|
215
|
+
socket.onmessage = async (event) => {
|
|
216
|
+
sessions.touch(sessionId);
|
|
217
|
+
try {
|
|
218
|
+
const data = typeof event.data === "string"
|
|
219
|
+
? JSON.parse(event.data)
|
|
220
|
+
: event.data;
|
|
221
|
+
// -------------------------------------------------------------------
|
|
222
|
+
// Auth-on-first-message: handle auth before any JSON-RPC
|
|
223
|
+
// -------------------------------------------------------------------
|
|
224
|
+
if (requiresAuth && !session.authenticated) {
|
|
225
|
+
if (data && data.type === "auth" && typeof data.initData === "string") {
|
|
226
|
+
const authResult = await validateTelegramInitData(data.initData, config.telegramBotToken);
|
|
227
|
+
if (authResult.valid) {
|
|
228
|
+
session.authenticated = true;
|
|
229
|
+
session.userId = authResult.userId;
|
|
230
|
+
session.username = authResult.username;
|
|
231
|
+
log("Authenticated session", sessionId, "userId:", authResult.userId);
|
|
232
|
+
if (socket.readyState === 1) {
|
|
233
|
+
socket.send(JSON.stringify({ type: "auth_ok", userId: authResult.userId }));
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
else {
|
|
237
|
+
log("Auth failed for", sessionId, ":", authResult.error);
|
|
238
|
+
if (socket.readyState === 1) {
|
|
239
|
+
socket.send(JSON.stringify({ type: "auth_error", error: authResult.error }));
|
|
240
|
+
socket.close(4001, "Authentication failed");
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
// Non-auth message on unauthenticated session — reject
|
|
246
|
+
log("Unauthenticated message from", sessionId, "— closing");
|
|
247
|
+
if (socket.readyState === 1) {
|
|
248
|
+
socket.send(JSON.stringify({ type: "auth_error", error: "Authentication required. Send { type: 'auth', initData: '...' } first." }));
|
|
249
|
+
socket.close(4003, "Authentication required");
|
|
250
|
+
}
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
// -------------------------------------------------------------------
|
|
254
|
+
// Normal JSON-RPC message handling (authenticated or auth not required)
|
|
255
|
+
// -------------------------------------------------------------------
|
|
256
|
+
if (!isJsonRpcMessage(data)) {
|
|
257
|
+
log("Non-JSON-RPC message from", sessionId);
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
const message = data;
|
|
261
|
+
log("Received from", sessionId, ":", message.method ?? "response");
|
|
262
|
+
if (config.onMessage) {
|
|
263
|
+
const response = await config.onMessage(session, message);
|
|
264
|
+
if (response && socket.readyState === 1 /* OPEN */) {
|
|
265
|
+
socket.send(JSON.stringify(response));
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
catch (err) {
|
|
270
|
+
log("Error handling message from", sessionId, ":", err);
|
|
271
|
+
}
|
|
272
|
+
};
|
|
273
|
+
socket.onclose = () => {
|
|
274
|
+
wsConnections.delete(sessionId);
|
|
275
|
+
log("WebSocket disconnected:", sessionId);
|
|
276
|
+
};
|
|
277
|
+
socket.onerror = (e) => {
|
|
278
|
+
log("WebSocket error:", sessionId, e);
|
|
279
|
+
};
|
|
280
|
+
return response;
|
|
281
|
+
}
|
|
282
|
+
// Serve MCP App assets: /app/<server>/<path>
|
|
283
|
+
if (path.startsWith("/app/")) {
|
|
284
|
+
return await serveAppAsset(path.slice(5), config, corsHeaders(request));
|
|
285
|
+
}
|
|
286
|
+
// Custom HTTP request handler (proxy, additional routes, etc.)
|
|
287
|
+
if (config.onHttpRequest) {
|
|
288
|
+
const result = await config.onHttpRequest(request);
|
|
289
|
+
if (result) {
|
|
290
|
+
if (result instanceof Response) {
|
|
291
|
+
return result;
|
|
292
|
+
}
|
|
293
|
+
// { html, pendingNotifications? } — inject bridge.js, create session, set CSP
|
|
294
|
+
// The original request is passed so serveProxiedHtml can auto-resolve ?ref=
|
|
295
|
+
return serveProxiedHtml(result.html, config, corsHeaders(request), request, result.pendingNotifications);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
return new Response("Not Found", { status: 404, headers: corsHeaders(request) });
|
|
299
|
+
}
|
|
300
|
+
async function serveAppAsset(assetPath, cfg, headers) {
|
|
301
|
+
// Parse: <server>/<file-path>
|
|
302
|
+
const slashIdx = assetPath.indexOf("/");
|
|
303
|
+
if (slashIdx < 0) {
|
|
304
|
+
return new Response("Invalid asset path", { status: 400, headers });
|
|
305
|
+
}
|
|
306
|
+
const serverName = assetPath.slice(0, slashIdx);
|
|
307
|
+
const filePath = assetPath.slice(slashIdx + 1) || "index.html";
|
|
308
|
+
const baseDir = cfg.assetDirectories[serverName];
|
|
309
|
+
if (!baseDir) {
|
|
310
|
+
return new Response(`Unknown app server: ${serverName}`, {
|
|
311
|
+
status: 404,
|
|
312
|
+
headers,
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
// Prevent path traversal: normalize both paths and compare
|
|
316
|
+
const normalizedBase = normalizeDir(baseDir);
|
|
317
|
+
const resolved = normalizePath(`${normalizedBase}/${filePath}`);
|
|
318
|
+
if (!resolved.startsWith(normalizedBase)) {
|
|
319
|
+
return new Response("Forbidden", { status: 403, headers });
|
|
320
|
+
}
|
|
321
|
+
try {
|
|
322
|
+
const mime = mimeType(filePath);
|
|
323
|
+
const isText = mime.startsWith("text/") ||
|
|
324
|
+
mime.startsWith("application/javascript") ||
|
|
325
|
+
mime.startsWith("application/json") ||
|
|
326
|
+
mime.startsWith("image/svg+xml");
|
|
327
|
+
if (isText) {
|
|
328
|
+
const content = await Deno.readTextFile(resolved);
|
|
329
|
+
// For HTML files, inject bridge script and set CSP
|
|
330
|
+
if (mime.startsWith("text/html")) {
|
|
331
|
+
const session = sessions.create(cfg.platform);
|
|
332
|
+
const bridgeScriptUrl = `/bridge.js?platform=${cfg.platform}&session=${session.id}`;
|
|
333
|
+
const injected = injectBridgeScript(content, bridgeScriptUrl);
|
|
334
|
+
const cspHeader = buildCspHeader(cfg.csp);
|
|
335
|
+
return new Response(injected, {
|
|
336
|
+
status: 200,
|
|
337
|
+
headers: {
|
|
338
|
+
...headers,
|
|
339
|
+
"Content-Type": mime,
|
|
340
|
+
"Content-Security-Policy": cspHeader,
|
|
341
|
+
},
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
return new Response(content, {
|
|
345
|
+
status: 200,
|
|
346
|
+
headers: { ...headers, "Content-Type": mime },
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
// Binary files (images, fonts, etc.)
|
|
350
|
+
const content = await Deno.readFile(resolved);
|
|
351
|
+
return new Response(content, {
|
|
352
|
+
status: 200,
|
|
353
|
+
headers: { ...headers, "Content-Type": mime },
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
catch (err) {
|
|
357
|
+
log("Asset not found:", resolved, err instanceof Error ? err.message : err);
|
|
358
|
+
return new Response("File not found", { status: 404, headers });
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
/**
|
|
362
|
+
* Serve externally-fetched HTML with bridge.js injection.
|
|
363
|
+
* Creates a session, injects bridge script, and sets CSP — same as local assets.
|
|
364
|
+
*
|
|
365
|
+
* Automatically checks for a `?ref=` query parameter on the original request.
|
|
366
|
+
* If found, looks up the stored tool result and builds a
|
|
367
|
+
* `ui/notifications/tool-result` notification buffered on the session.
|
|
368
|
+
*
|
|
369
|
+
* Additional `notifications` (from the `onHttpRequest` handler) are also
|
|
370
|
+
* buffered. The `?ref=` auto-notification is prepended before any extras.
|
|
371
|
+
*/
|
|
372
|
+
function serveProxiedHtml(html, cfg, headers, originalRequest, notifications) {
|
|
373
|
+
const session = sessions.create(cfg.platform);
|
|
374
|
+
// Auto-resolve ?ref= from the original request URL
|
|
375
|
+
const allNotifications = [];
|
|
376
|
+
if (originalRequest) {
|
|
377
|
+
const reqUrl = new URL(originalRequest.url);
|
|
378
|
+
const dataRef = reqUrl.searchParams.get("ref");
|
|
379
|
+
if (dataRef) {
|
|
380
|
+
const toolResult = consumeToolResult(dataRef);
|
|
381
|
+
if (toolResult) {
|
|
382
|
+
log("Auto-attaching tool-result for ref=" + dataRef);
|
|
383
|
+
allNotifications.push(buildToolResultFromData(toolResult));
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
if (notifications && notifications.length > 0) {
|
|
388
|
+
allNotifications.push(...notifications);
|
|
389
|
+
}
|
|
390
|
+
if (allNotifications.length > 0) {
|
|
391
|
+
session.pendingNotifications = allNotifications;
|
|
392
|
+
log("Buffered", allNotifications.length, "notification(s) for session", session.id);
|
|
393
|
+
}
|
|
394
|
+
const bridgeScriptUrl = `/bridge.js?platform=${cfg.platform}&session=${session.id}`;
|
|
395
|
+
const injected = injectBridgeScript(html, bridgeScriptUrl);
|
|
396
|
+
const cspHeader = buildCspHeader(cfg.csp);
|
|
397
|
+
return new Response(injected, {
|
|
398
|
+
status: 200,
|
|
399
|
+
headers: {
|
|
400
|
+
...headers,
|
|
401
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
402
|
+
"Content-Security-Policy": cspHeader,
|
|
403
|
+
},
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
// Resolve the path to bridge.js relative to this module
|
|
407
|
+
const bridgeJsPath = new URL("../client/bridge.js", import.meta.url);
|
|
408
|
+
async function serveBridgeScript(headers) {
|
|
409
|
+
try {
|
|
410
|
+
const content = await (await fetch(bridgeJsPath)).text();
|
|
411
|
+
return new Response(content, {
|
|
412
|
+
status: 200,
|
|
413
|
+
headers: {
|
|
414
|
+
...headers,
|
|
415
|
+
"Content-Type": "application/javascript; charset=utf-8",
|
|
416
|
+
"Cache-Control": "no-cache",
|
|
417
|
+
},
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
catch (err) {
|
|
421
|
+
log("Failed to serve bridge.js:", err instanceof Error ? err.message : err);
|
|
422
|
+
return new Response("bridge.js not found", { status: 500, headers });
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
// Start the server
|
|
426
|
+
// deno-lint-ignore no-explicit-any
|
|
427
|
+
const server = Deno.serve({
|
|
428
|
+
port,
|
|
429
|
+
handler: handleRequest,
|
|
430
|
+
onListen: (addr) => {
|
|
431
|
+
log(`Listening on http://localhost:${addr.port}`);
|
|
432
|
+
},
|
|
433
|
+
});
|
|
434
|
+
// Wait for the server to be ready and get the actual port
|
|
435
|
+
// Deno.serve returns a server with addr
|
|
436
|
+
const addr = server.addr;
|
|
437
|
+
const actualPort = addr?.port ?? port;
|
|
438
|
+
const baseUrl = `http://localhost:${actualPort}`;
|
|
439
|
+
return {
|
|
440
|
+
baseUrl,
|
|
441
|
+
sessions,
|
|
442
|
+
storeToolResult,
|
|
443
|
+
consumeToolResult,
|
|
444
|
+
async stop() {
|
|
445
|
+
clearInterval(cleanupInterval);
|
|
446
|
+
// Close all WebSocket connections
|
|
447
|
+
for (const [, ws] of wsConnections) {
|
|
448
|
+
try {
|
|
449
|
+
ws.close();
|
|
450
|
+
}
|
|
451
|
+
catch (err) {
|
|
452
|
+
log("Error closing WebSocket:", err instanceof Error ? err.message : err);
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
wsConnections.clear();
|
|
456
|
+
sessions.clear();
|
|
457
|
+
// Clear tool result timers to prevent leaks
|
|
458
|
+
for (const timer of toolResultTimers.values()) {
|
|
459
|
+
clearTimeout(timer);
|
|
460
|
+
}
|
|
461
|
+
toolResultTimers.clear();
|
|
462
|
+
toolResultStore.clear();
|
|
463
|
+
await server.shutdown();
|
|
464
|
+
},
|
|
465
|
+
};
|
|
466
|
+
}
|
|
467
|
+
// ---------------------------------------------------------------------------
|
|
468
|
+
// Helpers
|
|
469
|
+
// ---------------------------------------------------------------------------
|
|
470
|
+
/** Generate a random ref ID (32 hex chars = 128 bits of entropy). */
|
|
471
|
+
function generateRef() {
|
|
472
|
+
const bytes = new Uint8Array(16);
|
|
473
|
+
crypto.getRandomValues(bytes);
|
|
474
|
+
return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
|
|
475
|
+
}
|
|
476
|
+
/** Build a `ui/notifications/tool-result` pending notification from ToolResultData. */
|
|
477
|
+
export function buildToolResultFromData(data) {
|
|
478
|
+
return {
|
|
479
|
+
jsonrpc: "2.0",
|
|
480
|
+
method: "ui/notifications/tool-result",
|
|
481
|
+
params: data,
|
|
482
|
+
};
|
|
483
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session management for the resource server.
|
|
3
|
+
*
|
|
4
|
+
* Each connected webview gets a session that tracks:
|
|
5
|
+
* - The platform type
|
|
6
|
+
* - The current tool context (if a tool call is in flight)
|
|
7
|
+
* - Activity timestamps for cleanup
|
|
8
|
+
*/
|
|
9
|
+
/** A JSON-RPC notification to be sent to the app when the WebSocket connects. */
|
|
10
|
+
export interface PendingNotification {
|
|
11
|
+
readonly jsonrpc: "2.0";
|
|
12
|
+
readonly method: string;
|
|
13
|
+
readonly params?: Record<string, unknown>;
|
|
14
|
+
}
|
|
15
|
+
/** A bridge session representing one connected webview. */
|
|
16
|
+
export interface BridgeSession {
|
|
17
|
+
/** Unique session ID. */
|
|
18
|
+
readonly id: string;
|
|
19
|
+
/** Platform identifier (e.g. "telegram", "line"). */
|
|
20
|
+
readonly platform: string;
|
|
21
|
+
/** When the session was created (Unix ms). */
|
|
22
|
+
readonly createdAt: number;
|
|
23
|
+
/** Last activity timestamp (Unix ms). Updated on each message. */
|
|
24
|
+
lastActivity: number;
|
|
25
|
+
/** Whether the session has been authenticated (e.g. Telegram initData validated). */
|
|
26
|
+
authenticated: boolean;
|
|
27
|
+
/** Telegram user ID (set after successful auth). */
|
|
28
|
+
userId?: number;
|
|
29
|
+
/** Telegram username (set after successful auth). */
|
|
30
|
+
username?: string;
|
|
31
|
+
/** Notifications to send when the WebSocket connects (e.g. tool-result). */
|
|
32
|
+
pendingNotifications?: PendingNotification[];
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* In-memory session store.
|
|
36
|
+
*
|
|
37
|
+
* Sessions are created when a webview connects via WebSocket and
|
|
38
|
+
* cleaned up when the connection closes or after a timeout.
|
|
39
|
+
*/
|
|
40
|
+
export declare class SessionStore {
|
|
41
|
+
private readonly sessions;
|
|
42
|
+
private readonly maxAge;
|
|
43
|
+
/** @param maxAgeMs - Session TTL in ms. Defaults to 30 minutes. */
|
|
44
|
+
constructor(maxAgeMs?: number);
|
|
45
|
+
/** Create a new session. */
|
|
46
|
+
create(platform: string): BridgeSession;
|
|
47
|
+
/** Get a session by ID. Returns undefined if not found or expired. */
|
|
48
|
+
get(id: string): BridgeSession | undefined;
|
|
49
|
+
/** Update the last activity timestamp. */
|
|
50
|
+
touch(id: string): void;
|
|
51
|
+
/** Remove a session. */
|
|
52
|
+
remove(id: string): boolean;
|
|
53
|
+
/** Remove all expired sessions. */
|
|
54
|
+
cleanup(): number;
|
|
55
|
+
/** Number of active sessions. */
|
|
56
|
+
get size(): number;
|
|
57
|
+
/** Clear all sessions. */
|
|
58
|
+
clear(): void;
|
|
59
|
+
}
|
|
60
|
+
//# sourceMappingURL=session.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"session.d.ts","sourceRoot":"","sources":["../../src/resource-server/session.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,iFAAiF;AACjF,MAAM,WAAW,mBAAmB;IAClC,QAAQ,CAAC,OAAO,EAAE,KAAK,CAAC;IACxB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CAC3C;AAED,2DAA2D;AAC3D,MAAM,WAAW,aAAa;IAC5B,yBAAyB;IACzB,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAC;IACpB,qDAAqD;IACrD,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,8CAA8C;IAC9C,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,kEAAkE;IAClE,YAAY,EAAE,MAAM,CAAC;IACrB,qFAAqF;IACrF,aAAa,EAAE,OAAO,CAAC;IACvB,oDAAoD;IACpD,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,qDAAqD;IACrD,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,4EAA4E;IAC5E,oBAAoB,CAAC,EAAE,mBAAmB,EAAE,CAAC;CAC9C;AAED;;;;;GAKG;AACH,qBAAa,YAAY;IACvB,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAoC;IAC7D,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAS;IAEhC,mEAAmE;gBACvD,QAAQ,SAAiB;IAIrC,4BAA4B;IAC5B,MAAM,CAAC,QAAQ,EAAE,MAAM,GAAG,aAAa;IAcvC,sEAAsE;IACtE,GAAG,CAAC,EAAE,EAAE,MAAM,GAAG,aAAa,GAAG,SAAS;IAY1C,0CAA0C;IAC1C,KAAK,CAAC,EAAE,EAAE,MAAM,GAAG,IAAI;IAOvB,wBAAwB;IACxB,MAAM,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO;IAI3B,mCAAmC;IACnC,OAAO,IAAI,MAAM;IAYjB,iCAAiC;IACjC,IAAI,IAAI,IAAI,MAAM,CAEjB;IAED,0BAA0B;IAC1B,KAAK,IAAI,IAAI;CAGd"}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session management for the resource server.
|
|
3
|
+
*
|
|
4
|
+
* Each connected webview gets a session that tracks:
|
|
5
|
+
* - The platform type
|
|
6
|
+
* - The current tool context (if a tool call is in flight)
|
|
7
|
+
* - Activity timestamps for cleanup
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* In-memory session store.
|
|
11
|
+
*
|
|
12
|
+
* Sessions are created when a webview connects via WebSocket and
|
|
13
|
+
* cleaned up when the connection closes or after a timeout.
|
|
14
|
+
*/
|
|
15
|
+
export class SessionStore {
|
|
16
|
+
sessions = new Map();
|
|
17
|
+
maxAge;
|
|
18
|
+
/** @param maxAgeMs - Session TTL in ms. Defaults to 30 minutes. */
|
|
19
|
+
constructor(maxAgeMs = 30 * 60 * 1000) {
|
|
20
|
+
this.maxAge = maxAgeMs;
|
|
21
|
+
}
|
|
22
|
+
/** Create a new session. */
|
|
23
|
+
create(platform) {
|
|
24
|
+
const id = generateSessionId();
|
|
25
|
+
const now = Date.now();
|
|
26
|
+
const session = {
|
|
27
|
+
id,
|
|
28
|
+
platform,
|
|
29
|
+
createdAt: now,
|
|
30
|
+
lastActivity: now,
|
|
31
|
+
authenticated: false,
|
|
32
|
+
};
|
|
33
|
+
this.sessions.set(id, session);
|
|
34
|
+
return session;
|
|
35
|
+
}
|
|
36
|
+
/** Get a session by ID. Returns undefined if not found or expired. */
|
|
37
|
+
get(id) {
|
|
38
|
+
const session = this.sessions.get(id);
|
|
39
|
+
if (!session)
|
|
40
|
+
return undefined;
|
|
41
|
+
if (Date.now() - session.lastActivity > this.maxAge) {
|
|
42
|
+
this.sessions.delete(id);
|
|
43
|
+
return undefined;
|
|
44
|
+
}
|
|
45
|
+
return session;
|
|
46
|
+
}
|
|
47
|
+
/** Update the last activity timestamp. */
|
|
48
|
+
touch(id) {
|
|
49
|
+
const session = this.sessions.get(id);
|
|
50
|
+
if (session) {
|
|
51
|
+
session.lastActivity = Date.now();
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
/** Remove a session. */
|
|
55
|
+
remove(id) {
|
|
56
|
+
return this.sessions.delete(id);
|
|
57
|
+
}
|
|
58
|
+
/** Remove all expired sessions. */
|
|
59
|
+
cleanup() {
|
|
60
|
+
const now = Date.now();
|
|
61
|
+
let removed = 0;
|
|
62
|
+
for (const [id, session] of this.sessions) {
|
|
63
|
+
if (now - session.lastActivity > this.maxAge) {
|
|
64
|
+
this.sessions.delete(id);
|
|
65
|
+
removed++;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return removed;
|
|
69
|
+
}
|
|
70
|
+
/** Number of active sessions. */
|
|
71
|
+
get size() {
|
|
72
|
+
return this.sessions.size;
|
|
73
|
+
}
|
|
74
|
+
/** Clear all sessions. */
|
|
75
|
+
clear() {
|
|
76
|
+
this.sessions.clear();
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
// ---------------------------------------------------------------------------
|
|
80
|
+
// Helpers
|
|
81
|
+
// ---------------------------------------------------------------------------
|
|
82
|
+
function generateSessionId() {
|
|
83
|
+
const bytes = new Uint8Array(16);
|
|
84
|
+
crypto.getRandomValues(bytes);
|
|
85
|
+
return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
|
|
86
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Telegram Mini App initData HMAC-SHA256 server-side validation.
|
|
3
|
+
*
|
|
4
|
+
* Implements the algorithm documented at:
|
|
5
|
+
* @see https://core.telegram.org/bots/webapps#validating-data-received-via-the-mini-app
|
|
6
|
+
*
|
|
7
|
+
* Uses the Web Crypto API exclusively (works in Deno and Node.js 18+).
|
|
8
|
+
*/
|
|
9
|
+
/** Result of validating Telegram initData. */
|
|
10
|
+
export interface TelegramAuthResult {
|
|
11
|
+
/** Whether the initData is valid. */
|
|
12
|
+
readonly valid: boolean;
|
|
13
|
+
/** Telegram user ID (from the `user` JSON field). */
|
|
14
|
+
readonly userId?: number;
|
|
15
|
+
/** Telegram username. */
|
|
16
|
+
readonly username?: string;
|
|
17
|
+
/** User's first name. */
|
|
18
|
+
readonly firstName?: string;
|
|
19
|
+
/** User's last name. */
|
|
20
|
+
readonly lastName?: string;
|
|
21
|
+
/** Parsed auth_date as a Date object. */
|
|
22
|
+
readonly authDate?: Date;
|
|
23
|
+
/** Human-readable error message when valid is false. */
|
|
24
|
+
readonly error?: string;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Validate Telegram Mini App `initData`.
|
|
28
|
+
*
|
|
29
|
+
* Algorithm:
|
|
30
|
+
* 1. Parse the initData query string.
|
|
31
|
+
* 2. Extract and remove the `hash` parameter.
|
|
32
|
+
* 3. Sort remaining key=value pairs alphabetically by key.
|
|
33
|
+
* 4. Join them with `\n` to form data_check_string.
|
|
34
|
+
* 5. Derive secret_key = HMAC-SHA256(key="WebAppData", data=botToken).
|
|
35
|
+
* 6. Compute expected = HMAC-SHA256(key=secret_key, data=data_check_string).
|
|
36
|
+
* 7. Compare hex(expected) with hash using constant-time comparison.
|
|
37
|
+
* 8. Check auth_date freshness.
|
|
38
|
+
*
|
|
39
|
+
* @param initData - The raw initData query string from Telegram.
|
|
40
|
+
* @param botToken - The bot token used to sign the data.
|
|
41
|
+
* @param maxAgeSeconds - Maximum acceptable age of auth_date. Defaults to 86400 (24h).
|
|
42
|
+
* @returns A promise resolving to the validation result.
|
|
43
|
+
*/
|
|
44
|
+
export declare function validateTelegramInitData(initData: string, botToken: string, maxAgeSeconds?: number): Promise<TelegramAuthResult>;
|
|
45
|
+
//# sourceMappingURL=telegram-auth.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"telegram-auth.d.ts","sourceRoot":"","sources":["../../src/resource-server/telegram-auth.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAMH,8CAA8C;AAC9C,MAAM,WAAW,kBAAkB;IACjC,qCAAqC;IACrC,QAAQ,CAAC,KAAK,EAAE,OAAO,CAAC;IACxB,qDAAqD;IACrD,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IACzB,yBAAyB;IACzB,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAC3B,yBAAyB;IACzB,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;IAC5B,wBAAwB;IACxB,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAC3B,yCAAyC;IACzC,QAAQ,CAAC,QAAQ,CAAC,EAAE,IAAI,CAAC;IACzB,wDAAwD;IACxD,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC;CACzB;AASD;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAsB,wBAAwB,CAC5C,QAAQ,EAAE,MAAM,EAChB,QAAQ,EAAE,MAAM,EAChB,aAAa,GAAE,MAAgC,GAC9C,OAAO,CAAC,kBAAkB,CAAC,CA+G7B"}
|