@ggui-ai/mcp-server 0.1.0-rc.1
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/LICENSE +201 -0
- package/README.md +48 -0
- package/dist/admin-blueprints-transport.d.ts +114 -0
- package/dist/admin-blueprints-transport.d.ts.map +1 -0
- package/dist/admin-blueprints-transport.js +118 -0
- package/dist/admin-oauth-providers-transport.d.ts +40 -0
- package/dist/admin-oauth-providers-transport.d.ts.map +1 -0
- package/dist/admin-oauth-providers-transport.js +263 -0
- package/dist/auth.d.ts +39 -0
- package/dist/auth.d.ts.map +1 -0
- package/dist/auth.js +75 -0
- package/dist/build-mcp.d.ts +128 -0
- package/dist/build-mcp.d.ts.map +1 -0
- package/dist/build-mcp.js +113 -0
- package/dist/code-store-fs.d.ts +19 -0
- package/dist/code-store-fs.d.ts.map +1 -0
- package/dist/code-store-fs.js +98 -0
- package/dist/console-auth.d.ts +139 -0
- package/dist/console-auth.d.ts.map +1 -0
- package/dist/console-auth.js +102 -0
- package/dist/console-cache.d.ts +78 -0
- package/dist/console-cache.d.ts.map +1 -0
- package/dist/console-cache.js +105 -0
- package/dist/console-headers.d.ts +124 -0
- package/dist/console-headers.d.ts.map +1 -0
- package/dist/console-headers.js +49 -0
- package/dist/console-llm-trace.d.ts +66 -0
- package/dist/console-llm-trace.d.ts.map +1 -0
- package/dist/console-llm-trace.js +105 -0
- package/dist/console-payloads.d.ts +67 -0
- package/dist/console-payloads.d.ts.map +1 -0
- package/dist/console-payloads.js +105 -0
- package/dist/console-theme-routes.d.ts +111 -0
- package/dist/console-theme-routes.d.ts.map +1 -0
- package/dist/console-theme-routes.js +202 -0
- package/dist/console-timeline.d.ts +45 -0
- package/dist/console-timeline.d.ts.map +1 -0
- package/dist/console-timeline.js +169 -0
- package/dist/console-validator.d.ts +67 -0
- package/dist/console-validator.d.ts.map +1 -0
- package/dist/console-validator.js +105 -0
- package/dist/console-welcome.d.ts +7 -0
- package/dist/console-welcome.d.ts.map +1 -0
- package/dist/console-welcome.js +221 -0
- package/dist/csrf-middleware.d.ts +55 -0
- package/dist/csrf-middleware.d.ts.map +1 -0
- package/dist/csrf-middleware.js +138 -0
- package/dist/email-login.d.ts +174 -0
- package/dist/email-login.d.ts.map +1 -0
- package/dist/email-login.js +254 -0
- package/dist/email-resend.d.ts +29 -0
- package/dist/email-resend.d.ts.map +1 -0
- package/dist/email-resend.js +71 -0
- package/dist/email-sender-from-env.d.ts +34 -0
- package/dist/email-sender-from-env.d.ts.map +1 -0
- package/dist/email-sender-from-env.js +112 -0
- package/dist/email-smtp.d.ts +42 -0
- package/dist/email-smtp.d.ts.map +1 -0
- package/dist/email-smtp.js +81 -0
- package/dist/index.d.ts +102 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +122 -0
- package/dist/instructions-presets.d.ts +112 -0
- package/dist/instructions-presets.d.ts.map +1 -0
- package/dist/instructions-presets.js +195 -0
- package/dist/llm-backed-negotiator.d.ts +178 -0
- package/dist/llm-backed-negotiator.d.ts.map +1 -0
- package/dist/llm-backed-negotiator.js +579 -0
- package/dist/logger.d.ts +23 -0
- package/dist/logger.d.ts.map +1 -0
- package/dist/logger.js +41 -0
- package/dist/mcp-apps-inbound.d.ts +86 -0
- package/dist/mcp-apps-inbound.d.ts.map +1 -0
- package/dist/mcp-apps-inbound.js +278 -0
- package/dist/mcp-apps-outbound.d.ts +448 -0
- package/dist/mcp-apps-outbound.d.ts.map +1 -0
- package/dist/mcp-apps-outbound.js +1163 -0
- package/dist/mcp-mounts.d.ts +239 -0
- package/dist/mcp-mounts.d.ts.map +1 -0
- package/dist/mcp-mounts.js +222 -0
- package/dist/oauth-login-types.d.ts +160 -0
- package/dist/oauth-login-types.d.ts.map +1 -0
- package/dist/oauth-login-types.js +9 -0
- package/dist/oauth-login.d.ts +77 -0
- package/dist/oauth-login.d.ts.map +1 -0
- package/dist/oauth-login.js +455 -0
- package/dist/oauth-providers/github.d.ts +17 -0
- package/dist/oauth-providers/github.d.ts.map +1 -0
- package/dist/oauth-providers/github.js +89 -0
- package/dist/oauth-providers/google.d.ts +18 -0
- package/dist/oauth-providers/google.d.ts.map +1 -0
- package/dist/oauth-providers/google.js +59 -0
- package/dist/oauth-providers-store.d.ts +32 -0
- package/dist/oauth-providers-store.d.ts.map +1 -0
- package/dist/oauth-providers-store.js +291 -0
- package/dist/oauth.d.ts +347 -0
- package/dist/oauth.d.ts.map +1 -0
- package/dist/oauth.js +686 -0
- package/dist/pairing-transport.d.ts +99 -0
- package/dist/pairing-transport.d.ts.map +1 -0
- package/dist/pairing-transport.js +223 -0
- package/dist/rate-limit-middleware.d.ts +36 -0
- package/dist/rate-limit-middleware.d.ts.map +1 -0
- package/dist/rate-limit-middleware.js +57 -0
- package/dist/render-gate.d.ts +87 -0
- package/dist/render-gate.d.ts.map +1 -0
- package/dist/render-gate.js +77 -0
- package/dist/render-rate-limit.d.ts +59 -0
- package/dist/render-rate-limit.d.ts.map +1 -0
- package/dist/render-rate-limit.js +73 -0
- package/dist/render-signing.d.ts +98 -0
- package/dist/render-signing.d.ts.map +1 -0
- package/dist/render-signing.js +113 -0
- package/dist/request-context.d.ts +113 -0
- package/dist/request-context.d.ts.map +1 -0
- package/dist/request-context.js +154 -0
- package/dist/reserved-validators.d.ts +22 -0
- package/dist/reserved-validators.d.ts.map +1 -0
- package/dist/reserved-validators.js +101 -0
- package/dist/schema-compat.d.ts +167 -0
- package/dist/schema-compat.d.ts.map +1 -0
- package/dist/schema-compat.js +187 -0
- package/dist/security-headers-middleware.d.ts +38 -0
- package/dist/security-headers-middleware.d.ts.map +1 -0
- package/dist/security-headers-middleware.js +30 -0
- package/dist/server.d.ts +2060 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +6338 -0
- package/dist/session-channel.d.ts +651 -0
- package/dist/session-channel.d.ts.map +1 -0
- package/dist/session-channel.js +1756 -0
- package/dist/storage.d.ts +89 -0
- package/dist/storage.d.ts.map +1 -0
- package/dist/storage.js +171 -0
- package/dist/thread-transport.d.ts +118 -0
- package/dist/thread-transport.d.ts.map +1 -0
- package/dist/thread-transport.js +478 -0
- package/dist/user-session-auth.d.ts +167 -0
- package/dist/user-session-auth.d.ts.map +1 -0
- package/dist/user-session-auth.js +148 -0
- package/package.json +76 -0
|
@@ -0,0 +1,478 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { appendMessage, applyThreadAction, createThread, getThread, InvalidThreadActionError, InvalidThreadRequestError, listMessages, listThreads, observeMessages, ThreadActionInvalidStateError, ThreadNotFoundError, } from '@ggui-ai/mcp-server-handlers/threads';
|
|
3
|
+
import { resolveIdentity, UnauthenticatedError } from './auth.js';
|
|
4
|
+
/** Default URL prefix the thread routes are mounted at. */
|
|
5
|
+
export const DEFAULT_THREADS_PATH = '/threads';
|
|
6
|
+
export const DEFAULT_BUILDER_OWNER_ID = 'builder';
|
|
7
|
+
/**
|
|
8
|
+
* Default identity → ownerId mapping for the OSS server.
|
|
9
|
+
*
|
|
10
|
+
* Preserves the protocol's "self-hosted → typically `paired_<pairingId>`"
|
|
11
|
+
* convention whenever the token was minted by pairing: the bridge in
|
|
12
|
+
* `createGguiServer` passes `metadata: { pairingId }` on `onTokenIssued`,
|
|
13
|
+
* which surfaces here via `AuthResult.metadata.pairingId`. Fallbacks:
|
|
14
|
+
*
|
|
15
|
+
* - `source: 'cognito'` with a `sub` metadata field → `cognito_<sub>`
|
|
16
|
+
* (parallel adapters in any closed cloud runtime; not a shape the
|
|
17
|
+
* OSS server ships today but the mapping is stable so custom
|
|
18
|
+
* adapters are uniform).
|
|
19
|
+
* - `kind: 'user'` → `user_<workspaceId ?? userId>` — same partition
|
|
20
|
+
* rule `defaultAppIdFromIdentity` uses.
|
|
21
|
+
* - `kind: 'builder'` with no pairing metadata (dev mode / manual
|
|
22
|
+
* token) → {@link DEFAULT_BUILDER_OWNER_ID}.
|
|
23
|
+
*
|
|
24
|
+
* Keeping every OSS-reachable identity collapsed to ONE owner by
|
|
25
|
+
* default is correct: OSS is a single-operator tier. Operators who
|
|
26
|
+
* split owners across multiple tokens pass a custom resolver.
|
|
27
|
+
*/
|
|
28
|
+
export function defaultThreadOwnerFromIdentity(result) {
|
|
29
|
+
const pairingId = result.metadata?.['pairingId'];
|
|
30
|
+
if (typeof pairingId === 'string' && pairingId.length > 0) {
|
|
31
|
+
return `paired_${pairingId}`;
|
|
32
|
+
}
|
|
33
|
+
if (result.source === 'cognito') {
|
|
34
|
+
const sub = result.metadata?.['sub'];
|
|
35
|
+
if (typeof sub === 'string' && sub.length > 0)
|
|
36
|
+
return `cognito_${sub}`;
|
|
37
|
+
}
|
|
38
|
+
if (result.identity.kind === 'user') {
|
|
39
|
+
return `user_${result.identity.workspaceId ?? result.identity.userId}`;
|
|
40
|
+
}
|
|
41
|
+
// `kind: 'app'` is a per-app machine caller (e.g. an agent-builder
|
|
42
|
+
// API key hitting a hosted multi-tenant deployment). Threads owned
|
|
43
|
+
// by an app should be scoped to that app, NOT pooled into the
|
|
44
|
+
// default builder bucket — the latter would let two apps see each
|
|
45
|
+
// other's threads.
|
|
46
|
+
if (result.identity.kind === 'app') {
|
|
47
|
+
return `app_${result.identity.appId}`;
|
|
48
|
+
}
|
|
49
|
+
return DEFAULT_BUILDER_OWNER_ID;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Mount the six thread routes onto an existing Express app.
|
|
53
|
+
*
|
|
54
|
+
* Idempotent is NOT a goal — call once per server. `createGguiServer`
|
|
55
|
+
* owns the single call site via `opts.threads`.
|
|
56
|
+
*/
|
|
57
|
+
export function mountThreadTransport(app, opts) {
|
|
58
|
+
const prefix = opts.path ?? DEFAULT_THREADS_PATH;
|
|
59
|
+
const ownerFromIdentity = opts.ownerFromIdentity ?? defaultThreadOwnerFromIdentity;
|
|
60
|
+
const deps = { threads: opts.store };
|
|
61
|
+
// Resolve identity + ownerId in one place. Every route calls this
|
|
62
|
+
// first; on failure it writes the response and returns null so the
|
|
63
|
+
// route handler short-circuits. Returning null via a shared helper
|
|
64
|
+
// means no route accidentally proceeds without identity.
|
|
65
|
+
async function requireOwnerContext(req, res, routeLogger) {
|
|
66
|
+
const requestId = typeof req.headers['x-request-id'] === 'string'
|
|
67
|
+
? req.headers['x-request-id']
|
|
68
|
+
: randomUUID();
|
|
69
|
+
try {
|
|
70
|
+
const identity = await resolveIdentity(opts.auth, req);
|
|
71
|
+
const ownerId = ownerFromIdentity(identity);
|
|
72
|
+
return { ownerId, requestId };
|
|
73
|
+
}
|
|
74
|
+
catch (err) {
|
|
75
|
+
if (err instanceof UnauthenticatedError) {
|
|
76
|
+
routeLogger.warn('thread_auth_failed', { reason: err.message });
|
|
77
|
+
res.status(401).json({
|
|
78
|
+
error: { code: 'unauthenticated', message: err.message },
|
|
79
|
+
});
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
routeLogger.error('thread_auth_unexpected_error', {
|
|
83
|
+
error: String(err),
|
|
84
|
+
});
|
|
85
|
+
res.status(500).json({
|
|
86
|
+
error: { code: 'internal', message: 'Internal server error' },
|
|
87
|
+
});
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Map a thrown handler / store error to the stable HTTP envelope.
|
|
93
|
+
* Central for two reasons: (1) every route has the same mapping
|
|
94
|
+
* rules, so DRY; (2) adding a new error class is a one-liner here
|
|
95
|
+
* instead of six places.
|
|
96
|
+
*/
|
|
97
|
+
function handleError(err, res, routeLogger, routeLabel) {
|
|
98
|
+
if (err instanceof InvalidThreadRequestError) {
|
|
99
|
+
routeLogger.debug?.('thread_bad_request', {
|
|
100
|
+
route: routeLabel,
|
|
101
|
+
message: err.message,
|
|
102
|
+
});
|
|
103
|
+
res.status(400).json({
|
|
104
|
+
error: {
|
|
105
|
+
code: 'bad_request',
|
|
106
|
+
message: err.message,
|
|
107
|
+
details: { issues: err.issues },
|
|
108
|
+
},
|
|
109
|
+
});
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
if (err instanceof InvalidThreadActionError) {
|
|
113
|
+
// Handler-schema parsing rejects unknown actions first (→
|
|
114
|
+
// InvalidThreadRequestError). This branch covers direct-call
|
|
115
|
+
// paths where the store validates. 400 matches the "malformed
|
|
116
|
+
// action value" semantics.
|
|
117
|
+
routeLogger.debug?.('thread_invalid_action', {
|
|
118
|
+
route: routeLabel,
|
|
119
|
+
message: err.message,
|
|
120
|
+
});
|
|
121
|
+
res.status(400).json({
|
|
122
|
+
error: { code: 'bad_request', message: err.message },
|
|
123
|
+
});
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
if (err instanceof ThreadActionInvalidStateError) {
|
|
127
|
+
routeLogger.debug?.('thread_action_invalid_state', {
|
|
128
|
+
route: routeLabel,
|
|
129
|
+
message: err.message,
|
|
130
|
+
});
|
|
131
|
+
res.status(409).json({
|
|
132
|
+
error: { code: 'conflict', message: err.message },
|
|
133
|
+
});
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
if (err instanceof ThreadNotFoundError) {
|
|
137
|
+
routeLogger.debug?.('thread_not_found', {
|
|
138
|
+
route: routeLabel,
|
|
139
|
+
message: err.message,
|
|
140
|
+
});
|
|
141
|
+
res.status(404).json({
|
|
142
|
+
error: { code: 'not_found', message: err.message },
|
|
143
|
+
});
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
routeLogger.error('thread_route_unexpected_error', {
|
|
147
|
+
route: routeLabel,
|
|
148
|
+
error: String(err),
|
|
149
|
+
});
|
|
150
|
+
if (!res.headersSent) {
|
|
151
|
+
res.status(500).json({
|
|
152
|
+
error: { code: 'internal', message: 'Internal server error' },
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
// --- POST /threads ---
|
|
157
|
+
app.post(prefix, async (req, res) => {
|
|
158
|
+
const routeLogger = opts.logger.child({ route: 'POST ' + prefix });
|
|
159
|
+
const ctx = await requireOwnerContext(req, res, routeLogger);
|
|
160
|
+
if (!ctx)
|
|
161
|
+
return;
|
|
162
|
+
try {
|
|
163
|
+
const thread = await createThread(deps, req.body ?? {}, ctx);
|
|
164
|
+
res.status(201).json(thread);
|
|
165
|
+
}
|
|
166
|
+
catch (err) {
|
|
167
|
+
handleError(err, res, routeLogger, 'POST ' + prefix);
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
// --- GET /threads ---
|
|
171
|
+
//
|
|
172
|
+
// Filter comes from the query string. `limit` arrives as a string
|
|
173
|
+
// from Express; coerce before handing to the handler (the
|
|
174
|
+
// handler's zod schema would otherwise reject `"50"` as not a
|
|
175
|
+
// number). Everything else is already a string or absent.
|
|
176
|
+
app.get(prefix, async (req, res) => {
|
|
177
|
+
const routeLogger = opts.logger.child({ route: 'GET ' + prefix });
|
|
178
|
+
const ctx = await requireOwnerContext(req, res, routeLogger);
|
|
179
|
+
if (!ctx)
|
|
180
|
+
return;
|
|
181
|
+
try {
|
|
182
|
+
const filter = coerceQueryToFilter(req.query);
|
|
183
|
+
const result = await listThreads(deps, filter, ctx);
|
|
184
|
+
res.json(result);
|
|
185
|
+
}
|
|
186
|
+
catch (err) {
|
|
187
|
+
handleError(err, res, routeLogger, 'GET ' + prefix);
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
// --- GET /threads/:id ---
|
|
191
|
+
app.get(`${prefix}/:id`, async (req, res) => {
|
|
192
|
+
const routeLogger = opts.logger.child({ route: 'GET ' + prefix + '/:id' });
|
|
193
|
+
const ctx = await requireOwnerContext(req, res, routeLogger);
|
|
194
|
+
if (!ctx)
|
|
195
|
+
return;
|
|
196
|
+
try {
|
|
197
|
+
const thread = await getThread(deps, { threadId: req.params.id }, ctx);
|
|
198
|
+
res.json(thread);
|
|
199
|
+
}
|
|
200
|
+
catch (err) {
|
|
201
|
+
handleError(err, res, routeLogger, 'GET ' + prefix + '/:id');
|
|
202
|
+
}
|
|
203
|
+
});
|
|
204
|
+
// --- PATCH /threads/:id ---
|
|
205
|
+
//
|
|
206
|
+
// Body: `{ action: ThreadStateAction }`. PATCH picked (over POST
|
|
207
|
+
// on `/:id/actions`) because the action is a state transition on
|
|
208
|
+
// the resource, not a new sub-resource, and that matches how the
|
|
209
|
+
// cloud adapter frames it (`updateThreadState` mutation).
|
|
210
|
+
app.patch(`${prefix}/:id`, async (req, res) => {
|
|
211
|
+
const routeLogger = opts.logger.child({
|
|
212
|
+
route: 'PATCH ' + prefix + '/:id',
|
|
213
|
+
});
|
|
214
|
+
const ctx = await requireOwnerContext(req, res, routeLogger);
|
|
215
|
+
if (!ctx)
|
|
216
|
+
return;
|
|
217
|
+
try {
|
|
218
|
+
const thread = await applyThreadAction(deps, { threadId: req.params.id, body: req.body ?? {} }, ctx);
|
|
219
|
+
res.json(thread);
|
|
220
|
+
}
|
|
221
|
+
catch (err) {
|
|
222
|
+
handleError(err, res, routeLogger, 'PATCH ' + prefix + '/:id');
|
|
223
|
+
}
|
|
224
|
+
});
|
|
225
|
+
// --- GET /threads/:id/messages ---
|
|
226
|
+
app.get(`${prefix}/:id/messages`, async (req, res) => {
|
|
227
|
+
const routeLogger = opts.logger.child({
|
|
228
|
+
route: 'GET ' + prefix + '/:id/messages',
|
|
229
|
+
});
|
|
230
|
+
const ctx = await requireOwnerContext(req, res, routeLogger);
|
|
231
|
+
if (!ctx)
|
|
232
|
+
return;
|
|
233
|
+
try {
|
|
234
|
+
const result = await listMessages(deps, {
|
|
235
|
+
threadId: req.params.id,
|
|
236
|
+
options: coerceQueryToMessageOptions(req.query),
|
|
237
|
+
}, ctx);
|
|
238
|
+
res.json(result);
|
|
239
|
+
}
|
|
240
|
+
catch (err) {
|
|
241
|
+
handleError(err, res, routeLogger, 'GET ' + prefix + '/:id/messages');
|
|
242
|
+
}
|
|
243
|
+
});
|
|
244
|
+
// --- POST /threads/:id/messages ---
|
|
245
|
+
//
|
|
246
|
+
// The body carries every field except `threadId` (which comes from
|
|
247
|
+
// the URL). The handler requires `threadId` on its input envelope,
|
|
248
|
+
// so we fold it in here — caller-supplied `threadId` in the body
|
|
249
|
+
// (if any) is intentionally overwritten by the URL segment, which
|
|
250
|
+
// is the canonical truth for a RESTful path-scoped write.
|
|
251
|
+
app.post(`${prefix}/:id/messages`, async (req, res) => {
|
|
252
|
+
const routeLogger = opts.logger.child({
|
|
253
|
+
route: 'POST ' + prefix + '/:id/messages',
|
|
254
|
+
});
|
|
255
|
+
const ctx = await requireOwnerContext(req, res, routeLogger);
|
|
256
|
+
if (!ctx)
|
|
257
|
+
return;
|
|
258
|
+
try {
|
|
259
|
+
const body = typeof req.body === 'object' && req.body !== null ? req.body : {};
|
|
260
|
+
const input = { ...body, threadId: req.params.id };
|
|
261
|
+
const message = await appendMessage(deps, input, ctx);
|
|
262
|
+
res.status(201).json(message);
|
|
263
|
+
}
|
|
264
|
+
catch (err) {
|
|
265
|
+
handleError(err, res, routeLogger, 'POST ' + prefix + '/:id/messages');
|
|
266
|
+
}
|
|
267
|
+
});
|
|
268
|
+
// --- GET /threads/:id/stream (SSE) ---
|
|
269
|
+
//
|
|
270
|
+
// Serializes frames from the Step 3 `observeMessages` handler's
|
|
271
|
+
// AsyncIterable. Pre-header error handling is the interesting bit:
|
|
272
|
+
//
|
|
273
|
+
// 1. Resolve auth + ownerId (JSON-error channel).
|
|
274
|
+
// 2. Parse query options (JSON-error channel on shape failures).
|
|
275
|
+
// 3. Construct the iterator and call `.next()`.
|
|
276
|
+
// 4. Race that first `.next()` against `setImmediate`:
|
|
277
|
+
// - If it rejects synchronously → ThreadNotFoundError /
|
|
278
|
+
// InvalidThreadRequestError → write JSON error response
|
|
279
|
+
// with the right status code, NEVER write SSE headers.
|
|
280
|
+
// - If it resolves synchronously (backlog available or
|
|
281
|
+
// tail:false+empty backlog) → headers committed + that
|
|
282
|
+
// first result is sent as the initial SSE frame.
|
|
283
|
+
// - If it's still pending after one macrotask → ownership
|
|
284
|
+
// has passed (the store's wrong-owner check is
|
|
285
|
+
// synchronous), so headers can safely commit. The
|
|
286
|
+
// already-queued `firstNext` becomes the first frame
|
|
287
|
+
// whenever the first append lands.
|
|
288
|
+
//
|
|
289
|
+
// This keeps wrong-owner and missing-thread both 404'd BEFORE SSE
|
|
290
|
+
// headers are flushed — clients see the same error envelope they
|
|
291
|
+
// see on the JSON routes. The alternative (flush 200 then write a
|
|
292
|
+
// terminal error event) would silently convert a 404 into a 200,
|
|
293
|
+
// breaking the partition-indistinguishability rule.
|
|
294
|
+
app.get(`${prefix}/:id/stream`, async (req, res) => {
|
|
295
|
+
const routeLogger = opts.logger.child({
|
|
296
|
+
route: 'GET ' + prefix + '/:id/stream',
|
|
297
|
+
});
|
|
298
|
+
const ctx = await requireOwnerContext(req, res, routeLogger);
|
|
299
|
+
if (!ctx)
|
|
300
|
+
return;
|
|
301
|
+
// Parse fromSeq from query. Tail is always true for SSE — the
|
|
302
|
+
// whole point of the endpoint is a live subscription. If a
|
|
303
|
+
// future caller wants a one-shot snapshot they'd use
|
|
304
|
+
// `GET /messages`, not stream.
|
|
305
|
+
let iterable;
|
|
306
|
+
try {
|
|
307
|
+
iterable = observeMessages(deps, {
|
|
308
|
+
threadId: req.params.id,
|
|
309
|
+
options: coerceQueryToObserveOptions(req.query),
|
|
310
|
+
}, ctx);
|
|
311
|
+
}
|
|
312
|
+
catch (err) {
|
|
313
|
+
handleError(err, res, routeLogger, 'GET ' + prefix + '/:id/stream');
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
const iter = iterable[Symbol.asyncIterator]();
|
|
317
|
+
// Client-disconnect cleanup — set up BEFORE the race so that a
|
|
318
|
+
// disconnect during the ownership-probe window still disposes
|
|
319
|
+
// the iterator.
|
|
320
|
+
let clientClosed = false;
|
|
321
|
+
res.on('close', () => {
|
|
322
|
+
clientClosed = true;
|
|
323
|
+
if (iter.return) {
|
|
324
|
+
iter.return(undefined).catch(() => undefined);
|
|
325
|
+
}
|
|
326
|
+
});
|
|
327
|
+
// --- Ownership probe ---
|
|
328
|
+
// The store's observeMessages rejects the first `.next()`
|
|
329
|
+
// synchronously for wrong-owner / missing. On an empty thread
|
|
330
|
+
// with tail:true, the first `.next()` stays pending indefinitely.
|
|
331
|
+
// We distinguish by racing one macrotask.
|
|
332
|
+
const firstNext = iter.next();
|
|
333
|
+
const probe = await Promise.race([
|
|
334
|
+
firstNext.then((r) => ({ kind: 'settled', result: r }), (err) => ({ kind: 'rejected', error: err })),
|
|
335
|
+
new Promise((resolve) => setImmediate(() => resolve({ kind: 'pending' }))),
|
|
336
|
+
]);
|
|
337
|
+
if (probe.kind === 'rejected') {
|
|
338
|
+
handleError(probe.error, res, routeLogger, 'GET ' + prefix + '/:id/stream');
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
if (clientClosed) {
|
|
342
|
+
// Client gave up before we even committed headers. Nothing to
|
|
343
|
+
// send; iterator has already been disposed by the close hook.
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
// Commit SSE headers.
|
|
347
|
+
res.writeHead(200, {
|
|
348
|
+
'Content-Type': 'text/event-stream',
|
|
349
|
+
'Cache-Control': 'no-cache, no-transform',
|
|
350
|
+
Connection: 'keep-alive',
|
|
351
|
+
'X-Accel-Buffering': 'no',
|
|
352
|
+
});
|
|
353
|
+
res.flushHeaders?.();
|
|
354
|
+
// Consume the first result + continue the loop. The try/catch
|
|
355
|
+
// here covers post-header failures (e.g. a deferred store error
|
|
356
|
+
// surfacing through the pending firstNext). On that path we can't
|
|
357
|
+
// return 4xx — just log and close cleanly.
|
|
358
|
+
try {
|
|
359
|
+
if (probe.kind === 'settled') {
|
|
360
|
+
if (!probe.result.done) {
|
|
361
|
+
writeEventFrame(res, probe.result.value);
|
|
362
|
+
}
|
|
363
|
+
else {
|
|
364
|
+
res.end();
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
else {
|
|
369
|
+
// Probe ended as pending. firstNext still resolves in the
|
|
370
|
+
// normal path; await it like any subsequent iteration.
|
|
371
|
+
const first = await firstNext;
|
|
372
|
+
if (first.done) {
|
|
373
|
+
res.end();
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
writeEventFrame(res, first.value);
|
|
377
|
+
}
|
|
378
|
+
while (!clientClosed) {
|
|
379
|
+
const result = await iter.next();
|
|
380
|
+
if (result.done)
|
|
381
|
+
break;
|
|
382
|
+
writeEventFrame(res, result.value);
|
|
383
|
+
}
|
|
384
|
+
res.end();
|
|
385
|
+
}
|
|
386
|
+
catch (err) {
|
|
387
|
+
// Deferred error after headers are committed. Best-effort
|
|
388
|
+
// terminal-event shape (clients can still dedupe on seq), then
|
|
389
|
+
// close. NEVER reset the status code — headers are out.
|
|
390
|
+
routeLogger.warn('thread_stream_deferred_error', {
|
|
391
|
+
error: String(err),
|
|
392
|
+
});
|
|
393
|
+
if (!res.writableEnded) {
|
|
394
|
+
try {
|
|
395
|
+
res.write(`event: error\ndata: ${JSON.stringify({ message: String(err) })}\n\n`);
|
|
396
|
+
}
|
|
397
|
+
catch {
|
|
398
|
+
// Socket may be gone — swallow.
|
|
399
|
+
}
|
|
400
|
+
res.end();
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
/**
|
|
406
|
+
* Emit one ThreadMessage as an SSE frame. Frame shape:
|
|
407
|
+
*
|
|
408
|
+
* id: <seq>
|
|
409
|
+
* event: thread-message
|
|
410
|
+
* data: <JSON-encoded ThreadStreamEvent>
|
|
411
|
+
* \n
|
|
412
|
+
*
|
|
413
|
+
* The `id` field carries the `seq` so clients automatically get the
|
|
414
|
+
* Last-Event-ID reconnect hint (the browser's EventSource sends the
|
|
415
|
+
* last-seen id back on `?fromSeq=<last+1>`-aware reconnects; CLI
|
|
416
|
+
* consumers honor it manually). Event type is the shipped
|
|
417
|
+
* `ThreadStreamEvent` discriminant — v1 is `'thread-message'`;
|
|
418
|
+
* future additions stay forward-compatible because clients dedupe
|
|
419
|
+
* on seq + ignore unknown event types.
|
|
420
|
+
*/
|
|
421
|
+
function writeEventFrame(res, message) {
|
|
422
|
+
const event = { type: 'thread-message', message };
|
|
423
|
+
res.write(`id: ${message.seq}\nevent: thread-message\ndata: ${JSON.stringify(event)}\n\n`);
|
|
424
|
+
}
|
|
425
|
+
/**
|
|
426
|
+
* Coerce Express's query object (`string | string[] | ParsedQs | ...`)
|
|
427
|
+
* into the shape {@link listThreadsFilterSchema} expects. We only
|
|
428
|
+
* coerce fields the filter knows about; extras pass through untouched
|
|
429
|
+
* and the handler's strict schema rejects them (so a typo like
|
|
430
|
+
* `?stats=archived` returns 400 instead of silently ignoring the
|
|
431
|
+
* filter — catches client bugs fast).
|
|
432
|
+
*/
|
|
433
|
+
function coerceQueryToFilter(query) {
|
|
434
|
+
const out = {};
|
|
435
|
+
if (typeof query['status'] === 'string')
|
|
436
|
+
out['status'] = query['status'];
|
|
437
|
+
if (typeof query['appId'] === 'string')
|
|
438
|
+
out['appId'] = query['appId'];
|
|
439
|
+
if (typeof query['cursor'] === 'string')
|
|
440
|
+
out['cursor'] = query['cursor'];
|
|
441
|
+
if (typeof query['limit'] === 'string') {
|
|
442
|
+
const n = Number(query['limit']);
|
|
443
|
+
out['limit'] = Number.isFinite(n) ? n : query['limit'];
|
|
444
|
+
}
|
|
445
|
+
return out;
|
|
446
|
+
}
|
|
447
|
+
function coerceQueryToMessageOptions(query) {
|
|
448
|
+
const out = {};
|
|
449
|
+
if (typeof query['cursor'] === 'string')
|
|
450
|
+
out['cursor'] = query['cursor'];
|
|
451
|
+
if (typeof query['fromSeq'] === 'string') {
|
|
452
|
+
const n = Number(query['fromSeq']);
|
|
453
|
+
out['fromSeq'] = Number.isFinite(n) ? n : query['fromSeq'];
|
|
454
|
+
}
|
|
455
|
+
if (typeof query['limit'] === 'string') {
|
|
456
|
+
const n = Number(query['limit']);
|
|
457
|
+
out['limit'] = Number.isFinite(n) ? n : query['limit'];
|
|
458
|
+
}
|
|
459
|
+
return out;
|
|
460
|
+
}
|
|
461
|
+
/**
|
|
462
|
+
* Coerce SSE stream query params.
|
|
463
|
+
*
|
|
464
|
+
* `?fromSeq=<n>` is the only knob the stream endpoint honors — tail
|
|
465
|
+
* is always on (the endpoint IS a subscription). Limit is not
|
|
466
|
+
* meaningful for a stream and cursor is a paginated-read concept;
|
|
467
|
+
* both are omitted here and the handler's strict schema rejects
|
|
468
|
+
* them if passed (so a misdirected client query gets 400 instead of
|
|
469
|
+
* silently ignoring the knob).
|
|
470
|
+
*/
|
|
471
|
+
function coerceQueryToObserveOptions(query) {
|
|
472
|
+
const out = {};
|
|
473
|
+
if (typeof query['fromSeq'] === 'string') {
|
|
474
|
+
const n = Number(query['fromSeq']);
|
|
475
|
+
out['fromSeq'] = Number.isFinite(n) ? n : query['fromSeq'];
|
|
476
|
+
}
|
|
477
|
+
return out;
|
|
478
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* End-user browser session cookie auth plane.
|
|
3
|
+
*
|
|
4
|
+
* This is the FOURTH cookie kind the OSS server tracks. Distinct from
|
|
5
|
+
* the existing console session cookie (`ggui_console_session`,
|
|
6
|
+
* `console-auth.ts`) — that one's an HMAC token bound to
|
|
7
|
+
* `{sessionId, appId}` for narrow live-channel upgrade auth.
|
|
8
|
+
*
|
|
9
|
+
* The user-session cookie carries something different: **the raw
|
|
10
|
+
* pairing-minted bearer**, opaque to this module, validated downstream
|
|
11
|
+
* by the configured `AuthAdapter`. Architecture rationale:
|
|
12
|
+
*
|
|
13
|
+
* - One bearer per end-user, two transports.
|
|
14
|
+
*
|
|
15
|
+
* The pair-code consume flow (`POST /pair`) mints a bearer the
|
|
16
|
+
* MCP host (claude.ai's connector) sends as `Authorization: Bearer
|
|
17
|
+
* ...` on `/mcp` calls. The same bearer can also live in a
|
|
18
|
+
* same-origin HTTP-only cookie so the end-user's BROWSER (visiting
|
|
19
|
+
* the operator's `/settings` page) authenticates as the SAME
|
|
20
|
+
* identity. The AuthAdapter resolves both transports identically.
|
|
21
|
+
*
|
|
22
|
+
* - `/login` mints the cookie atomically with a fresh pair-code
|
|
23
|
+
* consume.
|
|
24
|
+
*
|
|
25
|
+
* Bearer paste at `/login` was rejected during the audit (session
|
|
26
|
+
* fixation: attacker tricks user into pasting attacker's bearer
|
|
27
|
+
* → user's actions land under attacker's identity). Only pair-code
|
|
28
|
+
* consume sets the cookie. The bearer never crosses an untrusted
|
|
29
|
+
* boundary.
|
|
30
|
+
*
|
|
31
|
+
* - Cookie value === bearer string verbatim.
|
|
32
|
+
*
|
|
33
|
+
* No HMAC-wrapping needed because the bearer is already a
|
|
34
|
+
* cryptographic credential the AuthAdapter validates. Wrapping
|
|
35
|
+
* would just add friction to the `Authorization: Bearer ...` ↔
|
|
36
|
+
* cookie symmetry. (The console session cookie HMACs because its
|
|
37
|
+
* payload is plain `{sessionId, appId}` claims, not a bearer.)
|
|
38
|
+
*
|
|
39
|
+
* Cookie attributes locked in the plan doc:
|
|
40
|
+
* - `HttpOnly` — JS can't read; XSS exfil blocked
|
|
41
|
+
* - `SameSite=Lax` — Strict breaks the Connect-Claude card
|
|
42
|
+
* cross-origin click flow (claude.ai iframe → ggui /settings)
|
|
43
|
+
* - `Secure` — TLS-only. Disabled for `secure: false` opt-out under
|
|
44
|
+
* localhost-only dev
|
|
45
|
+
* - `Path=/`
|
|
46
|
+
* - `Max-Age` — bound to bearer's pairing TTL
|
|
47
|
+
*
|
|
48
|
+
* What this module does NOT do:
|
|
49
|
+
* - Mint or verify HMAC tokens (that's bearer territory; AuthAdapter
|
|
50
|
+
* owns it).
|
|
51
|
+
* - Decide which routes require the cookie (composition-time;
|
|
52
|
+
* consumers wire `cookieAuthMiddleware` where they want
|
|
53
|
+
* cookie-or-header auth).
|
|
54
|
+
* - Rate-limiting, CSRF, audit hooks (handled by separate
|
|
55
|
+
* middleware).
|
|
56
|
+
*/
|
|
57
|
+
import type { IncomingHttpHeaders } from 'node:http';
|
|
58
|
+
import type { Request, RequestHandler } from 'express';
|
|
59
|
+
/**
|
|
60
|
+
* Cookie name. Distinct from `ggui_console_session` so cross-kind
|
|
61
|
+
* confusion is impossible — a cookie value never gets misinterpreted
|
|
62
|
+
* as a different auth artifact. Change carries a compat concern (the
|
|
63
|
+
* `/login` mint endpoint, `cookieAuthMiddleware`, and `/logout` clear
|
|
64
|
+
* read this exact name); keep this export as the single source of
|
|
65
|
+
* truth.
|
|
66
|
+
*/
|
|
67
|
+
export declare const USER_SESSION_COOKIE_NAME = "ggui_user_session";
|
|
68
|
+
/**
|
|
69
|
+
* Default cookie TTL (seconds). 8 hours mirrors the console session
|
|
70
|
+
* cookie default — operators leaving a tab open for a workday don't
|
|
71
|
+
* get re-prompted, but a stale cookie on a forgotten device expires
|
|
72
|
+
* before it ages further. Operators wanting longer sessions pass
|
|
73
|
+
* `ttlSec` at mount time.
|
|
74
|
+
*/
|
|
75
|
+
export declare const DEFAULT_USER_SESSION_TTL_SEC: number;
|
|
76
|
+
export interface FormatUserSessionCookieInput {
|
|
77
|
+
/** The pairing-minted bearer to embed as the cookie value. */
|
|
78
|
+
readonly bearer: string;
|
|
79
|
+
/** Cookie TTL in seconds. Defaults to {@link DEFAULT_USER_SESSION_TTL_SEC}. */
|
|
80
|
+
readonly ttlSec?: number;
|
|
81
|
+
/**
|
|
82
|
+
* Adds `Secure` when truthy — cookie only sent over HTTPS. Operators
|
|
83
|
+
* fronting the server with TLS pass `true`; localhost-only dev paths
|
|
84
|
+
* leave it falsy. No auto-detect — explicit is safer here than
|
|
85
|
+
* sniffing `req.protocol` (which lies behind reverse proxies).
|
|
86
|
+
*/
|
|
87
|
+
readonly secure?: boolean;
|
|
88
|
+
/** Cookie path. Defaults to `/`. */
|
|
89
|
+
readonly path?: string;
|
|
90
|
+
/**
|
|
91
|
+
* SameSite policy. Defaults to `'Lax'` per the audit decision —
|
|
92
|
+
* Strict would break the Connect-Claude card flow where the user
|
|
93
|
+
* clicks an `<a target="_blank">` from claude.ai's iframe to
|
|
94
|
+
* ggui's `/settings`. Lax permits top-level GET navigations to
|
|
95
|
+
* carry the cookie, which is what we need; CSRF on POSTs is
|
|
96
|
+
* defended via the CSRF-token middleware, not by cookie SameSite
|
|
97
|
+
* alone.
|
|
98
|
+
*/
|
|
99
|
+
readonly sameSite?: 'Strict' | 'Lax' | 'None';
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Format the `Set-Cookie` header value. Caller does
|
|
103
|
+
* `res.setHeader('Set-Cookie', formatUserSessionCookieHeader(...))`.
|
|
104
|
+
*
|
|
105
|
+
* The bearer is URL-encoded so values containing `;` / `,` / `=`
|
|
106
|
+
* survive the cookie wire format. `extractUserSessionCookie` decodes
|
|
107
|
+
* symmetrically.
|
|
108
|
+
*/
|
|
109
|
+
export declare function formatUserSessionCookieHeader(input: FormatUserSessionCookieInput): string;
|
|
110
|
+
/**
|
|
111
|
+
* Format a `Set-Cookie` header that immediately invalidates the
|
|
112
|
+
* user-session cookie. Used by `/logout` and `/revoke-bearer` so the
|
|
113
|
+
* browser drops it without waiting for `Max-Age`.
|
|
114
|
+
*
|
|
115
|
+
* `Max-Age=0` is the canonical clear pattern; matching attrs (Path,
|
|
116
|
+
* Secure) are required so the browser pairs this with the original
|
|
117
|
+
* mint and replaces it.
|
|
118
|
+
*/
|
|
119
|
+
export declare function formatClearUserSessionCookieHeader(input: {
|
|
120
|
+
readonly secure?: boolean;
|
|
121
|
+
readonly path?: string;
|
|
122
|
+
readonly sameSite?: 'Strict' | 'Lax' | 'None';
|
|
123
|
+
}): string;
|
|
124
|
+
/**
|
|
125
|
+
* Read the user-session cookie value from a `Cookie` header string.
|
|
126
|
+
* Returns the URL-decoded bearer, or `null` when the cookie is absent
|
|
127
|
+
* / malformed.
|
|
128
|
+
*
|
|
129
|
+
* Manual parser — same shape as `extractDevtoolCookie`. Tolerant of
|
|
130
|
+
* multi-cookie headers (`a=1; ggui_user_session=xyz; b=2`); rejects
|
|
131
|
+
* empty values rather than treating them as an authenticated bearer.
|
|
132
|
+
*/
|
|
133
|
+
export declare function extractUserSessionCookie(cookieHeader: string | undefined): string | null;
|
|
134
|
+
/**
|
|
135
|
+
* Read the cookie off Node `IncomingHttpHeaders` directly. Thin
|
|
136
|
+
* wrapper for callers operating below Express (raw `IncomingMessage`,
|
|
137
|
+
* WebSocket upgrade).
|
|
138
|
+
*/
|
|
139
|
+
export declare function readUserSessionCookieFromHeaders(headers: IncomingHttpHeaders): string | null;
|
|
140
|
+
/**
|
|
141
|
+
* Express middleware that bridges the cookie → header path.
|
|
142
|
+
*
|
|
143
|
+
* When a request arrives WITHOUT an `Authorization` header but WITH a
|
|
144
|
+
* valid user-session cookie, the middleware synthesizes
|
|
145
|
+
* `Authorization: Bearer <cookie>` so every existing
|
|
146
|
+
* `resolveIdentity(auth, req)` call works transparently. No new auth
|
|
147
|
+
* codepath, no new gate logic.
|
|
148
|
+
*
|
|
149
|
+
* When the request already has an `Authorization` header, the cookie
|
|
150
|
+
* is ignored — the explicit header wins. This preserves the existing
|
|
151
|
+
* `/mcp` ingress posture (claude.ai's connector sends Authorization;
|
|
152
|
+
* the cookie does not interfere with it).
|
|
153
|
+
*
|
|
154
|
+
* Composition-time: mount BEFORE any gate that reads
|
|
155
|
+
* `req.headers['authorization']` (the `/ggui/console/llm-keys` gate,
|
|
156
|
+
* the `/mcp` ingress, etc.). Order matters — middleware runs
|
|
157
|
+
* top-to-bottom in the Express chain.
|
|
158
|
+
*/
|
|
159
|
+
export declare function cookieAuthMiddleware(): RequestHandler;
|
|
160
|
+
/**
|
|
161
|
+
* Read the user-session cookie off an Express request. Used by
|
|
162
|
+
* `/logout` to know whether there's anything to clear, by
|
|
163
|
+
* `/revoke-bearer` to identify the pairing to revoke, and by tests
|
|
164
|
+
* that need to inspect the cookie without going through middleware.
|
|
165
|
+
*/
|
|
166
|
+
export declare function readUserSessionCookie(req: Request): string | null;
|
|
167
|
+
//# sourceMappingURL=user-session-auth.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"user-session-auth.d.ts","sourceRoot":"","sources":["../src/user-session-auth.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAuDG;AACH,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,WAAW,CAAC;AACrD,OAAO,KAAK,EAAE,OAAO,EAAE,cAAc,EAAE,MAAM,SAAS,CAAC;AAEvD;;;;;;;GAOG;AACH,eAAO,MAAM,wBAAwB,sBAAsB,CAAC;AAE5D;;;;;;GAMG;AACH,eAAO,MAAM,4BAA4B,QAAc,CAAC;AAExD,MAAM,WAAW,4BAA4B;IAC3C,8DAA8D;IAC9D,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,+EAA+E;IAC/E,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IACzB;;;;;OAKG;IACH,QAAQ,CAAC,MAAM,CAAC,EAAE,OAAO,CAAC;IAC1B,oCAAoC;IACpC,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAC;IACvB;;;;;;;;OAQG;IACH,QAAQ,CAAC,QAAQ,CAAC,EAAE,QAAQ,GAAG,KAAK,GAAG,MAAM,CAAC;CAC/C;AAED;;;;;;;GAOG;AACH,wBAAgB,6BAA6B,CAC3C,KAAK,EAAE,4BAA4B,GAClC,MAAM,CAWR;AAED;;;;;;;;GAQG;AACH,wBAAgB,kCAAkC,CAAC,KAAK,EAAE;IACxD,QAAQ,CAAC,MAAM,CAAC,EAAE,OAAO,CAAC;IAC1B,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,QAAQ,CAAC,EAAE,QAAQ,GAAG,KAAK,GAAG,MAAM,CAAC;CAC/C,GAAG,MAAM,CAUT;AAED;;;;;;;;GAQG;AACH,wBAAgB,wBAAwB,CACtC,YAAY,EAAE,MAAM,GAAG,SAAS,GAC/B,MAAM,GAAG,IAAI,CAqBf;AAED;;;;GAIG;AACH,wBAAgB,gCAAgC,CAC9C,OAAO,EAAE,mBAAmB,GAC3B,MAAM,GAAG,IAAI,CAIf;AAED;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,oBAAoB,IAAI,cAAc,CAYrD;AAED;;;;;GAKG;AACH,wBAAgB,qBAAqB,CAAC,GAAG,EAAE,OAAO,GAAG,MAAM,GAAG,IAAI,CAEjE"}
|