@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.
Files changed (141) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +48 -0
  3. package/dist/admin-blueprints-transport.d.ts +114 -0
  4. package/dist/admin-blueprints-transport.d.ts.map +1 -0
  5. package/dist/admin-blueprints-transport.js +118 -0
  6. package/dist/admin-oauth-providers-transport.d.ts +40 -0
  7. package/dist/admin-oauth-providers-transport.d.ts.map +1 -0
  8. package/dist/admin-oauth-providers-transport.js +263 -0
  9. package/dist/auth.d.ts +39 -0
  10. package/dist/auth.d.ts.map +1 -0
  11. package/dist/auth.js +75 -0
  12. package/dist/build-mcp.d.ts +128 -0
  13. package/dist/build-mcp.d.ts.map +1 -0
  14. package/dist/build-mcp.js +113 -0
  15. package/dist/code-store-fs.d.ts +19 -0
  16. package/dist/code-store-fs.d.ts.map +1 -0
  17. package/dist/code-store-fs.js +98 -0
  18. package/dist/console-auth.d.ts +139 -0
  19. package/dist/console-auth.d.ts.map +1 -0
  20. package/dist/console-auth.js +102 -0
  21. package/dist/console-cache.d.ts +78 -0
  22. package/dist/console-cache.d.ts.map +1 -0
  23. package/dist/console-cache.js +105 -0
  24. package/dist/console-headers.d.ts +124 -0
  25. package/dist/console-headers.d.ts.map +1 -0
  26. package/dist/console-headers.js +49 -0
  27. package/dist/console-llm-trace.d.ts +66 -0
  28. package/dist/console-llm-trace.d.ts.map +1 -0
  29. package/dist/console-llm-trace.js +105 -0
  30. package/dist/console-payloads.d.ts +67 -0
  31. package/dist/console-payloads.d.ts.map +1 -0
  32. package/dist/console-payloads.js +105 -0
  33. package/dist/console-theme-routes.d.ts +111 -0
  34. package/dist/console-theme-routes.d.ts.map +1 -0
  35. package/dist/console-theme-routes.js +202 -0
  36. package/dist/console-timeline.d.ts +45 -0
  37. package/dist/console-timeline.d.ts.map +1 -0
  38. package/dist/console-timeline.js +169 -0
  39. package/dist/console-validator.d.ts +67 -0
  40. package/dist/console-validator.d.ts.map +1 -0
  41. package/dist/console-validator.js +105 -0
  42. package/dist/console-welcome.d.ts +7 -0
  43. package/dist/console-welcome.d.ts.map +1 -0
  44. package/dist/console-welcome.js +221 -0
  45. package/dist/csrf-middleware.d.ts +55 -0
  46. package/dist/csrf-middleware.d.ts.map +1 -0
  47. package/dist/csrf-middleware.js +138 -0
  48. package/dist/email-login.d.ts +174 -0
  49. package/dist/email-login.d.ts.map +1 -0
  50. package/dist/email-login.js +254 -0
  51. package/dist/email-resend.d.ts +29 -0
  52. package/dist/email-resend.d.ts.map +1 -0
  53. package/dist/email-resend.js +71 -0
  54. package/dist/email-sender-from-env.d.ts +34 -0
  55. package/dist/email-sender-from-env.d.ts.map +1 -0
  56. package/dist/email-sender-from-env.js +112 -0
  57. package/dist/email-smtp.d.ts +42 -0
  58. package/dist/email-smtp.d.ts.map +1 -0
  59. package/dist/email-smtp.js +81 -0
  60. package/dist/index.d.ts +102 -0
  61. package/dist/index.d.ts.map +1 -0
  62. package/dist/index.js +122 -0
  63. package/dist/instructions-presets.d.ts +112 -0
  64. package/dist/instructions-presets.d.ts.map +1 -0
  65. package/dist/instructions-presets.js +195 -0
  66. package/dist/llm-backed-negotiator.d.ts +178 -0
  67. package/dist/llm-backed-negotiator.d.ts.map +1 -0
  68. package/dist/llm-backed-negotiator.js +579 -0
  69. package/dist/logger.d.ts +23 -0
  70. package/dist/logger.d.ts.map +1 -0
  71. package/dist/logger.js +41 -0
  72. package/dist/mcp-apps-inbound.d.ts +86 -0
  73. package/dist/mcp-apps-inbound.d.ts.map +1 -0
  74. package/dist/mcp-apps-inbound.js +278 -0
  75. package/dist/mcp-apps-outbound.d.ts +448 -0
  76. package/dist/mcp-apps-outbound.d.ts.map +1 -0
  77. package/dist/mcp-apps-outbound.js +1163 -0
  78. package/dist/mcp-mounts.d.ts +239 -0
  79. package/dist/mcp-mounts.d.ts.map +1 -0
  80. package/dist/mcp-mounts.js +222 -0
  81. package/dist/oauth-login-types.d.ts +160 -0
  82. package/dist/oauth-login-types.d.ts.map +1 -0
  83. package/dist/oauth-login-types.js +9 -0
  84. package/dist/oauth-login.d.ts +77 -0
  85. package/dist/oauth-login.d.ts.map +1 -0
  86. package/dist/oauth-login.js +455 -0
  87. package/dist/oauth-providers/github.d.ts +17 -0
  88. package/dist/oauth-providers/github.d.ts.map +1 -0
  89. package/dist/oauth-providers/github.js +89 -0
  90. package/dist/oauth-providers/google.d.ts +18 -0
  91. package/dist/oauth-providers/google.d.ts.map +1 -0
  92. package/dist/oauth-providers/google.js +59 -0
  93. package/dist/oauth-providers-store.d.ts +32 -0
  94. package/dist/oauth-providers-store.d.ts.map +1 -0
  95. package/dist/oauth-providers-store.js +291 -0
  96. package/dist/oauth.d.ts +347 -0
  97. package/dist/oauth.d.ts.map +1 -0
  98. package/dist/oauth.js +686 -0
  99. package/dist/pairing-transport.d.ts +99 -0
  100. package/dist/pairing-transport.d.ts.map +1 -0
  101. package/dist/pairing-transport.js +223 -0
  102. package/dist/rate-limit-middleware.d.ts +36 -0
  103. package/dist/rate-limit-middleware.d.ts.map +1 -0
  104. package/dist/rate-limit-middleware.js +57 -0
  105. package/dist/render-gate.d.ts +87 -0
  106. package/dist/render-gate.d.ts.map +1 -0
  107. package/dist/render-gate.js +77 -0
  108. package/dist/render-rate-limit.d.ts +59 -0
  109. package/dist/render-rate-limit.d.ts.map +1 -0
  110. package/dist/render-rate-limit.js +73 -0
  111. package/dist/render-signing.d.ts +98 -0
  112. package/dist/render-signing.d.ts.map +1 -0
  113. package/dist/render-signing.js +113 -0
  114. package/dist/request-context.d.ts +113 -0
  115. package/dist/request-context.d.ts.map +1 -0
  116. package/dist/request-context.js +154 -0
  117. package/dist/reserved-validators.d.ts +22 -0
  118. package/dist/reserved-validators.d.ts.map +1 -0
  119. package/dist/reserved-validators.js +101 -0
  120. package/dist/schema-compat.d.ts +167 -0
  121. package/dist/schema-compat.d.ts.map +1 -0
  122. package/dist/schema-compat.js +187 -0
  123. package/dist/security-headers-middleware.d.ts +38 -0
  124. package/dist/security-headers-middleware.d.ts.map +1 -0
  125. package/dist/security-headers-middleware.js +30 -0
  126. package/dist/server.d.ts +2060 -0
  127. package/dist/server.d.ts.map +1 -0
  128. package/dist/server.js +6338 -0
  129. package/dist/session-channel.d.ts +651 -0
  130. package/dist/session-channel.d.ts.map +1 -0
  131. package/dist/session-channel.js +1756 -0
  132. package/dist/storage.d.ts +89 -0
  133. package/dist/storage.d.ts.map +1 -0
  134. package/dist/storage.js +171 -0
  135. package/dist/thread-transport.d.ts +118 -0
  136. package/dist/thread-transport.d.ts.map +1 -0
  137. package/dist/thread-transport.js +478 -0
  138. package/dist/user-session-auth.d.ts +167 -0
  139. package/dist/user-session-auth.d.ts.map +1 -0
  140. package/dist/user-session-auth.js +148 -0
  141. 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"}