@juspay/neurolink 13.8.24 → 13.8.25

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.
@@ -64,7 +64,10 @@ export function createAllRoutes(basePath = "/api", options) {
64
64
  ? []
65
65
  : [createToolRoutes(basePath, sensitive)]),
66
66
  createMCPRoutes(basePath, sensitive),
67
- createMemoryRoutes(basePath),
67
+ createMemoryRoutes(basePath, {
68
+ ...options?.memory,
69
+ perUserAuth: options?.perUserAuth,
70
+ }),
68
71
  createHealthRoutes(basePath),
69
72
  createTranscribeRoutes(basePath, options?.transcribe),
70
73
  ];
@@ -2,10 +2,43 @@
2
2
  * Memory Routes
3
3
  * Endpoints for conversation memory management
4
4
  */
5
- import type { RouteGroup } from "../../types/index.js";
5
+ import type { RouteGroup, ServerMemoryRouteOptions } from "../../types/index.js";
6
6
  /**
7
7
  * Create memory management routes
8
8
  * Note: These routes provide a simplified interface to conversation memory.
9
9
  * The actual implementation depends on the memory manager type (ConversationMemoryManager or RedisConversationMemoryManager).
10
+ *
11
+ * By default the routes ignore who is calling: sessions are chosen by id (and
12
+ * `?userId=` on Redis), and statistics and clear-all are open to every caller
13
+ * that gets past your authentication. That is right for no authentication or one
14
+ * shared API key. Set `options.perUserAuth` (any truthy value) when the
15
+ * authentication middleware identifies individual people: every route but
16
+ * `GET /memory/health` then needs an authenticated user (401 otherwise), a
17
+ * caller reaches only its own sessions, and statistics and clear-all need a role
18
+ * in `options.adminRoles` (default `["admin"]`). A `?userId=` that is not a
19
+ * single value (a repeated `?userId=a&userId=b`) is refused with 400, and a user
20
+ * whose `roles` is not an array has no roles. A manager that can only report
21
+ * statistics (no `listSessions`, not Redis) shows an ordinary user an empty
22
+ * listing with a total of 0, never the count of everyone's sessions.
23
+ *
24
+ * `perUserAuth` needs `ctx.user` in the handler, which an adapter hands over
25
+ * only when the server carries middleware state. `registerAllRoutes()` turns
26
+ * that on for the server when it is given `perUserAuth`. A caller that registers
27
+ * these routes itself with `perUserAuth` must create the server with
28
+ * `carryMiddlewareState: true`. Without it, the user the authentication
29
+ * middleware sets does not reach the routes, so every request whose context
30
+ * has no `ctx.user` is answered 401, except `GET /memory/health`; a custom
31
+ * adapter or direct handler call that sets `ctx.user` itself is served as that
32
+ * user.
33
+ *
34
+ * On Redis, sessions are keyed by user, so an admin reaches another user's
35
+ * session only by naming that user in `?userId=`. The Redis key is
36
+ * `<keyPrefix><userId>:<sessionId>` with neither part escaped, so under
37
+ * `perUserAuth` a `:` in the authenticated user's id, an admin's `?userId=` or
38
+ * the session id is refused with 400 on every manager; otherwise user `a` asking
39
+ * for session `b:c` would reach user `a:b`'s session `c`. A session created
40
+ * without a `userId` is stored under the reserved user id `session-only`, which
41
+ * the routes refuse with 400 as the authenticated user's id or an admin's
42
+ * `?userId=`, so under `perUserAuth` no request reaches it.
10
43
  */
11
- export declare function createMemoryRoutes(basePath?: string): RouteGroup;
44
+ export declare function createMemoryRoutes(basePath?: string, options?: ServerMemoryRouteOptions): RouteGroup;
@@ -5,6 +5,7 @@
5
5
  import { withSpan } from "../../telemetry/withSpan.js";
6
6
  import { tracers } from "../../telemetry/tracers.js";
7
7
  import { shapeRouteError } from "../utils/clientError.js";
8
+ import { SESSION_ONLY_USER_ID } from "../../utils/redis.js";
8
9
  import { createErrorResponse, IdParamSchema, SessionIdParamSchema, validateParams, } from "../utils/validation.js";
9
10
  /**
10
11
  * Wrap a route handler with an OTel span for HTTP observability.
@@ -19,16 +20,132 @@ function tracedMemoryHandler(name, route, fn) {
19
20
  },
20
21
  }, () => fn(ctx));
21
22
  }
23
+ const DEFAULT_ADMIN_ROLES = ["admin"];
24
+ /**
25
+ * Under `perUserAuth` a `:` is refused in every part of a session's key the
26
+ * routes take from the request: the authenticated user's id, an admin's
27
+ * `?userId=` and the session id. The Redis manager joins them unescaped as
28
+ * `<keyPrefix><userId>:<sessionId>`, so with a `:` in either part two different
29
+ * pairs share a key, and user `a` asking for session `b:c` would reach user
30
+ * `a:b`'s session `c`. The rule is the same on every manager, so whether a
31
+ * request is accepted does not depend on the backend.
32
+ */
33
+ function refuseColon(ctx, what) {
34
+ return createErrorResponse("VALIDATION_ERROR", `${what} must not contain ':' when the memory routes are scoped with perUserAuth`, undefined, ctx.requestId);
35
+ }
36
+ /**
37
+ * Under `perUserAuth` the user id `session-only` is refused as well, as the
38
+ * authenticated user's id and as an admin's `?userId=`. It is the key a session
39
+ * created without a `userId` is stored under on Redis, and the Redis manager
40
+ * refuses it as a user id, so a request naming it could only miss; refusing it
41
+ * here, on every manager, says why instead of answering `404`.
42
+ */
43
+ function refuseReservedUserId(ctx, what) {
44
+ return createErrorResponse("VALIDATION_ERROR", `${what} must not be '${SESSION_ONLY_USER_ID}', the user id reserved for sessions created without one, when the memory routes are scoped with perUserAuth`, undefined, ctx.requestId);
45
+ }
46
+ /**
47
+ * Who is calling. Without `perUserAuth` the routes do not read `ctx.user` at
48
+ * all: no auth and one shared API key (which sets the same `ctx.user` for every
49
+ * caller) both leave nothing to scope by, so the actor carries no user and every
50
+ * route behaves as it always has. With `perUserAuth` (any truthy value, as for
51
+ * the agent routes, so a JavaScript caller passing `"true"` or `1` still fails
52
+ * closed) the request must carry an authenticated user with a non-empty id;
53
+ * anything else is refused with 401, because an anonymous request is otherwise
54
+ * indistinguishable from the trusted unscoped one. A user whose `roles` is not
55
+ * an array (a malformed authentication middleware) has no roles, so it is an
56
+ * ordinary user, never a 500.
57
+ */
58
+ function resolveMemoryActor(ctx, options) {
59
+ if (!options?.perUserAuth) {
60
+ return { user: undefined, admin: false };
61
+ }
62
+ const user = ctx.user;
63
+ if (!user || typeof user.id !== "string" || user.id.length === 0) {
64
+ return createErrorResponse("AUTH_REQUIRED", "Memory routes require an authenticated user", undefined, ctx.requestId);
65
+ }
66
+ if (user.id.includes(":")) {
67
+ return refuseColon(ctx, "The authenticated user id");
68
+ }
69
+ if (user.id === SESSION_ONLY_USER_ID) {
70
+ return refuseReservedUserId(ctx, "The authenticated user id");
71
+ }
72
+ const adminRoles = options.adminRoles ?? DEFAULT_ADMIN_ROLES;
73
+ return {
74
+ user,
75
+ admin: Array.isArray(user.roles) &&
76
+ user.roles.some((role) => adminRoles.includes(role)),
77
+ };
78
+ }
79
+ /**
80
+ * Whose sessions this request is about. Under `perUserAuth` the caller is always
81
+ * itself: `?userId=` may repeat its own id, and only an admin may name another.
82
+ * Taking the user from the query string is what let any authenticated caller
83
+ * read another user's conversations. Without it the query string is the only
84
+ * source, as it always was.
85
+ *
86
+ * Express, Fastify and Koa hand a repeated `?userId=a&userId=b` to the handler
87
+ * as an array (and an application that configures a bracket-parsing query
88
+ * parser gets one for `?userId[]=x` too). Under `perUserAuth` such a value is
89
+ * refused with 400 rather than read as "absent": reading it as absent would
90
+ * answer about a different user than the one the caller asked for (the caller's
91
+ * own sessions, or for an admin on the in-memory manager every session).
92
+ * Without `perUserAuth` the value is passed on untouched, as it always was.
93
+ */
94
+ function resolveSubjectUserId(ctx, actor) {
95
+ const requested = ctx.query.userId;
96
+ if (!actor.user) {
97
+ return { userId: ctx.query.userId };
98
+ }
99
+ if (requested !== undefined && typeof requested !== "string") {
100
+ return createErrorResponse("VALIDATION_ERROR", "userId must be a single value", undefined, ctx.requestId);
101
+ }
102
+ if (requested?.includes(":")) {
103
+ return refuseColon(ctx, "userId");
104
+ }
105
+ if (requested === SESSION_ONLY_USER_ID) {
106
+ return refuseReservedUserId(ctx, "userId");
107
+ }
108
+ if (!requested || requested === actor.user.id) {
109
+ return { userId: actor.user.id };
110
+ }
111
+ if (actor.admin) {
112
+ return { userId: requested };
113
+ }
114
+ return createErrorResponse("FORBIDDEN", "userId does not match the authenticated user", undefined, ctx.requestId);
115
+ }
116
+ /**
117
+ * The in-memory manager keys sessions by id alone, so under `perUserAuth`
118
+ * ownership is the session's own `userId`. A session with none belongs to no one
119
+ * a caller can prove to be themselves, so only an admin sees it.
120
+ */
121
+ function ownsSession(actor, session) {
122
+ if (!actor.user || actor.admin) {
123
+ return true;
124
+ }
125
+ const owner = session?.userId;
126
+ return typeof owner === "string" && owner === actor.user.id;
127
+ }
22
128
  /**
23
129
  * Handler: Get messages for a session
24
130
  */
25
- async function handleGetSessionMessages(ctx) {
131
+ async function handleGetSessionMessages(ctx, options) {
132
+ const actor = resolveMemoryActor(ctx, options);
133
+ if ("error" in actor) {
134
+ return actor;
135
+ }
26
136
  // Validate path params
27
137
  const paramValidation = validateParams(IdParamSchema, ctx.params, ctx.requestId);
28
138
  if (!paramValidation.success) {
29
139
  return paramValidation.error;
30
140
  }
31
141
  const { id: sessionId } = paramValidation.data;
142
+ if (actor.user && sessionId.includes(":")) {
143
+ return refuseColon(ctx, "The session id");
144
+ }
145
+ const subject = resolveSubjectUserId(ctx, actor);
146
+ if ("error" in subject) {
147
+ return subject;
148
+ }
32
149
  // Parse query params for pagination
33
150
  const limitParam = ctx.query.limit ? parseInt(ctx.query.limit, 10) : 50;
34
151
  const offsetParam = ctx.query.offset ? parseInt(ctx.query.offset, 10) : 0;
@@ -44,7 +161,7 @@ async function handleGetSessionMessages(ctx) {
44
161
  // Handle Redis memory manager (has getUserSessionHistory)
45
162
  if ("getUserSessionHistory" in memory) {
46
163
  // For Redis, we need a userId - check query params or use a default
47
- const userId = ctx.query.userId || "default";
164
+ const userId = subject.userId || "default";
48
165
  const sessionMessages = await memory.getUserSessionHistory(userId, sessionId);
49
166
  if (sessionMessages === null) {
50
167
  return createErrorResponse("SESSION_NOT_FOUND", `Session '${sessionId}' not found`, undefined, ctx.requestId);
@@ -54,13 +171,17 @@ async function handleGetSessionMessages(ctx) {
54
171
  // Handle in-memory manager (has getSession)
55
172
  else if ("getSession" in memory) {
56
173
  const session = memory.getSession(sessionId);
57
- if (!session) {
174
+ if (!session || !ownsSession(actor, session)) {
58
175
  return createErrorResponse("SESSION_NOT_FOUND", `Session '${sessionId}' not found`, undefined, ctx.requestId);
59
176
  }
60
177
  messages = session.messages;
61
178
  }
62
179
  // Fallback: try buildContextMessages if available
63
180
  else if ("buildContextMessages" in memory) {
181
+ // Ownership cannot be established for this manager.
182
+ if (actor.user && !actor.admin) {
183
+ return createErrorResponse("SESSION_NOT_FOUND", `Session '${sessionId}' not found`, undefined, ctx.requestId);
184
+ }
64
185
  messages = await memory.buildContextMessages(sessionId);
65
186
  // If no messages, session might not exist
66
187
  if (messages.length === 0) {
@@ -88,13 +209,24 @@ async function handleGetSessionMessages(ctx) {
88
209
  /**
89
210
  * Handler: Get session by ID
90
211
  */
91
- async function handleGetSession(ctx) {
212
+ async function handleGetSession(ctx, options) {
213
+ const actor = resolveMemoryActor(ctx, options);
214
+ if ("error" in actor) {
215
+ return actor;
216
+ }
92
217
  // Validate path params
93
218
  const paramValidation = validateParams(IdParamSchema, ctx.params, ctx.requestId);
94
219
  if (!paramValidation.success) {
95
220
  return paramValidation.error;
96
221
  }
97
222
  const { id: sessionId } = paramValidation.data;
223
+ if (actor.user && sessionId.includes(":")) {
224
+ return refuseColon(ctx, "The session id");
225
+ }
226
+ const subject = resolveSubjectUserId(ctx, actor);
227
+ if ("error" in subject) {
228
+ return subject;
229
+ }
98
230
  const memory = ctx.neurolink.conversationMemory;
99
231
  if (!memory) {
100
232
  return createErrorResponse("MEMORY_UNAVAILABLE", "Conversation memory not available", undefined, ctx.requestId);
@@ -103,7 +235,7 @@ async function handleGetSession(ctx) {
103
235
  // Handle Redis memory manager (has getUserSessionObject or getUserSessionMetadata)
104
236
  if ("getUserSessionObject" in memory) {
105
237
  // For Redis, we need a userId - check query params or use a default
106
- const userId = ctx.query.userId || "default";
238
+ const userId = subject.userId || "default";
107
239
  const sessionObject = await memory.getUserSessionObject(userId, sessionId);
108
240
  if (sessionObject === null) {
109
241
  return createErrorResponse("SESSION_NOT_FOUND", `Session '${sessionId}' not found`, undefined, ctx.requestId);
@@ -119,7 +251,7 @@ async function handleGetSession(ctx) {
119
251
  // Handle in-memory manager (has getSession)
120
252
  else if ("getSession" in memory) {
121
253
  const session = memory.getSession(sessionId);
122
- if (!session) {
254
+ if (!session || !ownsSession(actor, session)) {
123
255
  return createErrorResponse("SESSION_NOT_FOUND", `Session '${sessionId}' not found`, undefined, ctx.requestId);
124
256
  }
125
257
  return {
@@ -139,9 +271,17 @@ async function handleGetSession(ctx) {
139
271
  /**
140
272
  * Handler: List all conversation sessions
141
273
  */
142
- async function handleListSessions(ctx) {
274
+ async function handleListSessions(ctx, options) {
275
+ const actor = resolveMemoryActor(ctx, options);
276
+ if ("error" in actor) {
277
+ return actor;
278
+ }
279
+ const subject = resolveSubjectUserId(ctx, actor);
280
+ if ("error" in subject) {
281
+ return subject;
282
+ }
143
283
  // Parse query params
144
- const userId = ctx.query.userId;
284
+ const userId = subject.userId;
145
285
  const limitParam = ctx.query.limit ? parseInt(ctx.query.limit, 10) : 50;
146
286
  const offsetParam = ctx.query.offset ? parseInt(ctx.query.offset, 10) : 0;
147
287
  // Validate pagination params
@@ -171,18 +311,33 @@ async function handleListSessions(ctx) {
171
311
  sessions = sessionIds.map((id) => ({ id }));
172
312
  }
173
313
  }
314
+ // In-memory manager under perUserAuth: a user sees their own sessions, an
315
+ // admin every session (or one user's with ?userId=), rather than the stats
316
+ // branch's empty list and a count of everyone's.
317
+ else if (actor.user && "listSessions" in memory) {
318
+ const scope = actor.admin ? ctx.query.userId || undefined : actor.user.id;
319
+ sessions = await memory.listSessions(scope);
320
+ }
174
321
  // Handle in-memory manager - iterate over internal sessions Map
175
322
  else if ("getStats" in memory) {
176
323
  // For in-memory, we can use getSession to check if sessions exist
177
324
  // but we need to know the session IDs first. The in-memory manager
178
325
  // doesn't expose a listSessions method, so we return stats info instead.
179
- const stats = await memory.getStats?.();
326
+ //
327
+ // Under perUserAuth that total counts every user's sessions, and a manager
328
+ // that gets here cannot say which of them are the caller's. An ordinary
329
+ // user therefore gets an empty list and a total of 0, and the manager is
330
+ // not asked; an admin, who may read statistics anyway, gets the total.
331
+ const hidesTotal = Boolean(actor.user) && !actor.admin;
332
+ const stats = hidesTotal ? undefined : await memory.getStats?.();
180
333
  return {
181
334
  sessions: [],
182
335
  total: stats?.totalSessions || 0,
183
336
  limit,
184
337
  offset,
185
- message: "Session listing not fully supported for in-memory storage. Use /stats endpoint for session counts.",
338
+ message: hidesTotal
339
+ ? "This memory manager cannot list sessions per user."
340
+ : "Session listing not fully supported for in-memory storage. Use /stats endpoint for session counts.",
186
341
  metadata: {
187
342
  timestamp: new Date().toISOString(),
188
343
  requestId: ctx.requestId,
@@ -209,7 +364,14 @@ async function handleListSessions(ctx) {
209
364
  /**
210
365
  * Handler: Get memory statistics
211
366
  */
212
- async function handleGetStats(ctx) {
367
+ async function handleGetStats(ctx, options) {
368
+ const actor = resolveMemoryActor(ctx, options);
369
+ if ("error" in actor) {
370
+ return actor;
371
+ }
372
+ if (actor.user && !actor.admin) {
373
+ return createErrorResponse("FORBIDDEN", "Memory statistics require an admin role", undefined, ctx.requestId);
374
+ }
213
375
  const memory = ctx.neurolink.conversationMemory;
214
376
  if (!memory) {
215
377
  return createErrorResponse("MEMORY_UNAVAILABLE", "Conversation memory not available", undefined, ctx.requestId);
@@ -233,20 +395,44 @@ async function handleGetStats(ctx) {
233
395
  /**
234
396
  * Handler: Clear a conversation session
235
397
  */
236
- async function handleClearSession(ctx) {
398
+ async function handleClearSession(ctx, options) {
399
+ const actor = resolveMemoryActor(ctx, options);
400
+ if ("error" in actor) {
401
+ return actor;
402
+ }
237
403
  // Validate params
238
404
  const paramValidation = validateParams(SessionIdParamSchema, ctx.params, ctx.requestId);
239
405
  if (!paramValidation.success) {
240
406
  return paramValidation.error;
241
407
  }
242
408
  const { sessionId } = paramValidation.data;
409
+ if (actor.user && sessionId.includes(":")) {
410
+ return refuseColon(ctx, "The session id");
411
+ }
412
+ const subject = resolveSubjectUserId(ctx, actor);
413
+ if ("error" in subject) {
414
+ return subject;
415
+ }
243
416
  const memory = ctx.neurolink.conversationMemory;
244
417
  if (!memory) {
245
418
  return createErrorResponse("MEMORY_UNAVAILABLE", "Conversation memory not available", undefined, ctx.requestId);
246
419
  }
247
420
  try {
248
- // Use clearSession for ConversationMemoryManager
249
- const cleared = await memory.clearSession?.(sessionId);
421
+ const redisBacked = "getUserSessionObject" in memory;
422
+ if (actor.user && !actor.admin && !redisBacked) {
423
+ const existing = "getSession" in memory
424
+ ? memory.getSession(sessionId)
425
+ : undefined;
426
+ if (!existing || !ownsSession(actor, existing)) {
427
+ return createErrorResponse("SESSION_NOT_FOUND", `Session '${sessionId}' not found`, undefined, ctx.requestId);
428
+ }
429
+ }
430
+ // The Redis manager derives the key from the user, so under perUserAuth hand
431
+ // it the one this request is scoped to; the in-memory manager ignores the
432
+ // argument. Without perUserAuth the call is as it always was, with no user.
433
+ const cleared = actor.user && redisBacked
434
+ ? await memory.clearSession?.(sessionId, subject.userId)
435
+ : await memory.clearSession?.(sessionId);
250
436
  if (cleared === false) {
251
437
  return createErrorResponse("SESSION_NOT_FOUND", `Session '${sessionId}' not found`, undefined, ctx.requestId);
252
438
  }
@@ -267,7 +453,14 @@ async function handleClearSession(ctx) {
267
453
  /**
268
454
  * Handler: Clear all conversation sessions
269
455
  */
270
- async function handleClearAllSessions(ctx) {
456
+ async function handleClearAllSessions(ctx, options) {
457
+ const actor = resolveMemoryActor(ctx, options);
458
+ if ("error" in actor) {
459
+ return actor;
460
+ }
461
+ if (actor.user && !actor.admin) {
462
+ return createErrorResponse("FORBIDDEN", "Clearing every session requires an admin role", undefined, ctx.requestId);
463
+ }
271
464
  const memory = ctx.neurolink.conversationMemory;
272
465
  if (!memory) {
273
466
  return createErrorResponse("MEMORY_UNAVAILABLE", "Conversation memory not available", undefined, ctx.requestId);
@@ -303,8 +496,41 @@ async function handleMemoryHealth(ctx) {
303
496
  * Create memory management routes
304
497
  * Note: These routes provide a simplified interface to conversation memory.
305
498
  * The actual implementation depends on the memory manager type (ConversationMemoryManager or RedisConversationMemoryManager).
499
+ *
500
+ * By default the routes ignore who is calling: sessions are chosen by id (and
501
+ * `?userId=` on Redis), and statistics and clear-all are open to every caller
502
+ * that gets past your authentication. That is right for no authentication or one
503
+ * shared API key. Set `options.perUserAuth` (any truthy value) when the
504
+ * authentication middleware identifies individual people: every route but
505
+ * `GET /memory/health` then needs an authenticated user (401 otherwise), a
506
+ * caller reaches only its own sessions, and statistics and clear-all need a role
507
+ * in `options.adminRoles` (default `["admin"]`). A `?userId=` that is not a
508
+ * single value (a repeated `?userId=a&userId=b`) is refused with 400, and a user
509
+ * whose `roles` is not an array has no roles. A manager that can only report
510
+ * statistics (no `listSessions`, not Redis) shows an ordinary user an empty
511
+ * listing with a total of 0, never the count of everyone's sessions.
512
+ *
513
+ * `perUserAuth` needs `ctx.user` in the handler, which an adapter hands over
514
+ * only when the server carries middleware state. `registerAllRoutes()` turns
515
+ * that on for the server when it is given `perUserAuth`. A caller that registers
516
+ * these routes itself with `perUserAuth` must create the server with
517
+ * `carryMiddlewareState: true`. Without it, the user the authentication
518
+ * middleware sets does not reach the routes, so every request whose context
519
+ * has no `ctx.user` is answered 401, except `GET /memory/health`; a custom
520
+ * adapter or direct handler call that sets `ctx.user` itself is served as that
521
+ * user.
522
+ *
523
+ * On Redis, sessions are keyed by user, so an admin reaches another user's
524
+ * session only by naming that user in `?userId=`. The Redis key is
525
+ * `<keyPrefix><userId>:<sessionId>` with neither part escaped, so under
526
+ * `perUserAuth` a `:` in the authenticated user's id, an admin's `?userId=` or
527
+ * the session id is refused with 400 on every manager; otherwise user `a` asking
528
+ * for session `b:c` would reach user `a:b`'s session `c`. A session created
529
+ * without a `userId` is stored under the reserved user id `session-only`, which
530
+ * the routes refuse with 400 as the authenticated user's id or an admin's
531
+ * `?userId=`, so under `perUserAuth` no request reaches it.
306
532
  */
307
- export function createMemoryRoutes(basePath = "/api") {
533
+ export function createMemoryRoutes(basePath = "/api", options) {
308
534
  return {
309
535
  prefix: `${basePath}/memory`,
310
536
  routes: [
@@ -313,7 +539,7 @@ export function createMemoryRoutes(basePath = "/api") {
313
539
  {
314
540
  method: "GET",
315
541
  path: `${basePath}/memory/sessions/:id/messages`,
316
- handler: tracedMemoryHandler("http.memory.getSessionMessages", `${basePath}/memory/sessions/:id/messages`, handleGetSessionMessages),
542
+ handler: tracedMemoryHandler("http.memory.getSessionMessages", `${basePath}/memory/sessions/:id/messages`, (ctx) => handleGetSessionMessages(ctx, options)),
317
543
  description: "Get messages for a session",
318
544
  tags: ["memory"],
319
545
  },
@@ -321,7 +547,7 @@ export function createMemoryRoutes(basePath = "/api") {
321
547
  {
322
548
  method: "GET",
323
549
  path: `${basePath}/memory/sessions/:id`,
324
- handler: tracedMemoryHandler("http.memory.getSession", `${basePath}/memory/sessions/:id`, handleGetSession),
550
+ handler: tracedMemoryHandler("http.memory.getSession", `${basePath}/memory/sessions/:id`, (ctx) => handleGetSession(ctx, options)),
325
551
  description: "Get session by ID",
326
552
  tags: ["memory"],
327
553
  },
@@ -329,28 +555,28 @@ export function createMemoryRoutes(basePath = "/api") {
329
555
  {
330
556
  method: "GET",
331
557
  path: `${basePath}/memory/sessions`,
332
- handler: tracedMemoryHandler("http.memory.listSessions", `${basePath}/memory/sessions`, handleListSessions),
558
+ handler: tracedMemoryHandler("http.memory.listSessions", `${basePath}/memory/sessions`, (ctx) => handleListSessions(ctx, options)),
333
559
  description: "List all conversation sessions",
334
560
  tags: ["memory"],
335
561
  },
336
562
  {
337
563
  method: "GET",
338
564
  path: `${basePath}/memory/stats`,
339
- handler: tracedMemoryHandler("http.memory.stats", `${basePath}/memory/stats`, handleGetStats),
565
+ handler: tracedMemoryHandler("http.memory.stats", `${basePath}/memory/stats`, (ctx) => handleGetStats(ctx, options)),
340
566
  description: "Get memory statistics",
341
567
  tags: ["memory"],
342
568
  },
343
569
  {
344
570
  method: "DELETE",
345
571
  path: `${basePath}/memory/sessions/:sessionId`,
346
- handler: tracedMemoryHandler("http.memory.clearSession", `${basePath}/memory/sessions/:sessionId`, handleClearSession),
572
+ handler: tracedMemoryHandler("http.memory.clearSession", `${basePath}/memory/sessions/:sessionId`, (ctx) => handleClearSession(ctx, options)),
347
573
  description: "Clear a conversation session",
348
574
  tags: ["memory"],
349
575
  },
350
576
  {
351
577
  method: "DELETE",
352
578
  path: `${basePath}/memory/sessions`,
353
- handler: tracedMemoryHandler("http.memory.clearAllSessions", `${basePath}/memory/sessions`, handleClearAllSessions),
579
+ handler: tracedMemoryHandler("http.memory.clearAllSessions", `${basePath}/memory/sessions`, (ctx) => handleClearAllSessions(ctx, options)),
354
580
  description: "Clear all conversation sessions",
355
581
  tags: ["memory"],
356
582
  },
@@ -1414,12 +1414,15 @@ export type CreateRoutesOptions = {
1414
1414
  * people (one credential per person). The agent routes then take `userId`
1415
1415
  * and the session only from the authenticated user and ignore the request
1416
1416
  * body's, on every adapter and for every request; a request with no
1417
- * authenticated user gets neither. Any truthy value turns it on.
1417
+ * authenticated user gets neither. The memory routes scope every session to
1418
+ * that user too (see `ServerMemoryRouteOptions`). Any truthy value turns it
1419
+ * on.
1418
1420
  *
1419
1421
  * **A server with per-person authentication must set this.** Left at the
1420
1422
  * default (`false`) requests choose `userId` and `sessionId` in the body, so
1421
- * any authenticated caller can name another person there and act as them.
1422
- * The one exception is Hono, where a request whose client sent its own
1423
+ * any authenticated caller can name another person there and act as them,
1424
+ * and the memory routes ignore who is calling. The agent routes have one
1425
+ * exception, Hono, where a request whose client sent its own
1423
1426
  * `X-Request-ID` header reaches the handler with the middleware's context and
1424
1427
  * an authenticated `ctx.user` wins over the body, as before, and so on a
1425
1428
  * context the built-in adapters did not build (your own adapter, a handler
@@ -1445,6 +1448,70 @@ export type CreateRoutesOptions = {
1445
1448
  * `X-Request-ID`, and every other request runs as anonymous.
1446
1449
  */
1447
1450
  perUserAuth?: boolean;
1451
+ /**
1452
+ * Options for the conversation-memory routes (`<basePath>/memory/*`): which
1453
+ * roles are admins when `perUserAuth` is on. Whether the routes are scoped is
1454
+ * decided by the top-level `perUserAuth`, so it is not accepted here.
1455
+ */
1456
+ memory?: Omit<ServerMemoryRouteOptions, "perUserAuth">;
1457
+ };
1458
+ /** Options for `createMemoryRoutes()`. */
1459
+ export type ServerMemoryRouteOptions = {
1460
+ /**
1461
+ * Set to `true` when the authentication middleware identifies individual
1462
+ * people (one credential per person). Every memory route but
1463
+ * `GET <basePath>/memory/health` then needs an authenticated user (`401`
1464
+ * without one, or with an empty id) and is scoped to it: a caller reaches
1465
+ * only its own sessions, and statistics and clear-all need an admin role.
1466
+ * Any truthy value turns it on.
1467
+ *
1468
+ * Leave it unset (default `false`) for no authentication or one shared API
1469
+ * key, such as `neurolink serve` with `NEUROLINK_SERVER_API_KEY`. A shared key
1470
+ * names no person, so the routes do not read `ctx.user` at all and behave as
1471
+ * they always have: `?userId=` chooses the user on Redis, in-memory sessions
1472
+ * are keyed by id alone, and statistics and clear-all are open to every
1473
+ * caller that passes your authentication.
1474
+ *
1475
+ * The routes can only read `ctx.user` if the adapter hands it to the handler,
1476
+ * which it does when the server carries middleware state.
1477
+ * `registerAllRoutes` turns that on when given `perUserAuth`; a caller that
1478
+ * registers `createMemoryRoutes` itself must create the server with
1479
+ * `carryMiddlewareState: true`, or every request but `GET /memory/health` is
1480
+ * answered `401`.
1481
+ *
1482
+ * "Only its own sessions" holds on every manager because the routes refuse a
1483
+ * `:` (400) in the authenticated user's id, an admin's `?userId=` and the
1484
+ * session id: the Redis manager joins user and session unescaped as
1485
+ * `<keyPrefix><userId>:<sessionId>`, where user `a` asking for session `b:c`
1486
+ * would otherwise reach user `a:b`'s session `c`. A deployment whose user ids
1487
+ * contain `:` cannot use the scoped routes. On Redis a session created
1488
+ * without a `userId` shares the key of a user whose id is `session-only`, so
1489
+ * do not issue that id.
1490
+ */
1491
+ perUserAuth?: boolean;
1492
+ /**
1493
+ * Roles allowed to read another user's sessions, read memory statistics and
1494
+ * clear every session. Default `["admin"]`. Only consulted with
1495
+ * `perUserAuth`.
1496
+ *
1497
+ * On Redis, sessions are keyed per user, so an admin reaches another user's
1498
+ * session by naming that user in `?userId=` (without it the admin's own
1499
+ * sessions). A session created without a `userId` is stored under
1500
+ * `<keyPrefix>session-only:<sessionId>`, the key of a user named
1501
+ * `session-only`: an admin reaches it by naming `?userId=session-only` (so
1502
+ * does an ordinary user whose own id is `session-only`), no other name
1503
+ * reaches it, and clear-all removes it. The in-memory manager lists every
1504
+ * session for an admin.
1505
+ */
1506
+ adminRoles?: string[];
1507
+ };
1508
+ /**
1509
+ * Who is calling a memory route. `user` is the authenticated user the request
1510
+ * is scoped to, and `undefined` when the routes are unscoped (no `perUserAuth`).
1511
+ */
1512
+ export type ServerMemoryActor = {
1513
+ user: AuthenticatedUser | undefined;
1514
+ admin: boolean;
1448
1515
  };
1449
1516
  /**
1450
1517
  * Options of the route groups that act on the host (tool execution, MCP
@@ -3,6 +3,12 @@
3
3
  * Helper functions for Redis storage operations
4
4
  */
5
5
  import type { ChatMessage, RedisClient, RedisConversationObject, RedisStorageConfig } from "../types/index.js";
6
+ /**
7
+ * The user id a session created without a `userId` is stored under. It is
8
+ * refused as a caller's user id (`describeRedisIdProblem`), so no user can name
9
+ * the sessions stored under it.
10
+ */
11
+ export declare const SESSION_ONLY_USER_ID = "session-only";
6
12
  /**
7
13
  * Why an id is not usable as a key segment, or `null` when it is.
8
14
  */
@@ -4,7 +4,13 @@
4
4
  */
5
5
  import { randomUUID } from "crypto";
6
6
  import { logger } from "./logger.js";
7
- const SESSION_ONLY_PREFIX = "session-only:";
7
+ /**
8
+ * The user id a session created without a `userId` is stored under. It is
9
+ * refused as a caller's user id (`describeRedisIdProblem`), so no user can name
10
+ * the sessions stored under it.
11
+ */
12
+ export const SESSION_ONLY_USER_ID = "session-only";
13
+ const SESSION_ONLY_PREFIX = `${SESSION_ONLY_USER_ID}:`;
8
14
  /**
9
15
  * Longest id that may become part of a key. Redis itself allows far more, but
10
16
  * a session or user id past this length is not an id.