@juspay/neurolink 13.8.24 → 13.8.26
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/CHANGELOG.md +2 -2
- package/dist/browser/neurolink.min.js +413 -413
- package/dist/server/routes/index.js +4 -1
- package/dist/server/routes/memoryRoutes.d.ts +35 -2
- package/dist/server/routes/memoryRoutes.js +248 -22
- package/dist/types/server.d.ts +70 -3
- package/dist/utils/redis.d.ts +6 -0
- package/dist/utils/redis.js +7 -1
- package/docs-site/static/search-index.json +4 -3
- package/package.json +4 -3
|
@@ -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 =
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
249
|
-
|
|
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
|
},
|
package/dist/types/server.d.ts
CHANGED
|
@@ -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.
|
|
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
|
-
*
|
|
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
|
package/dist/utils/redis.d.ts
CHANGED
|
@@ -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
|
*/
|
package/dist/utils/redis.js
CHANGED
|
@@ -4,7 +4,13 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import { randomUUID } from "crypto";
|
|
6
6
|
import { logger } from "./logger.js";
|
|
7
|
-
|
|
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.
|