From 8bb0ed9b4decc81c008603f7222123e90aa700f2 Mon Sep 17 00:00:00 2001 From: 3em0 <59153706+3em0@users.noreply.github.com> Date: Wed, 27 May 2026 13:20:21 +0000 Subject: [PATCH] fix: enforce memory rest user authorization --- .changeset/quiet-memory-guards.md | 8 + .../src/handlers/memory.handlers.spec.ts | 210 +++++++++++++++ .../src/handlers/memory.handlers.ts | 241 ++++++++++++++++-- .../server-elysia/src/routes/memory.routes.ts | 44 +++- .../server-hono/src/routes/memory.routes.ts | 20 ++ packages/serverless-hono/src/routes.ts | 20 ++ 6 files changed, 505 insertions(+), 38 deletions(-) create mode 100644 .changeset/quiet-memory-guards.md create mode 100644 packages/server-core/src/handlers/memory.handlers.spec.ts diff --git a/.changeset/quiet-memory-guards.md b/.changeset/quiet-memory-guards.md new file mode 100644 index 000000000..c71c78230 --- /dev/null +++ b/.changeset/quiet-memory-guards.md @@ -0,0 +1,8 @@ +--- +"@voltagent/server-core": patch +"@voltagent/server-hono": patch +"@voltagent/server-elysia": patch +"@voltagent/serverless-hono": patch +--- + +Bind memory REST API operations to the authenticated user to prevent cross-user conversation access. diff --git a/packages/server-core/src/handlers/memory.handlers.spec.ts b/packages/server-core/src/handlers/memory.handlers.spec.ts new file mode 100644 index 000000000..be055c036 --- /dev/null +++ b/packages/server-core/src/handlers/memory.handlers.spec.ts @@ -0,0 +1,210 @@ +import { Memory } from "@voltagent/core"; +import type { Agent, Logger, ServerProviderDeps, VoltOpsClient } from "@voltagent/core"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { InMemoryStorageAdapter } from "../../../core/src/memory/adapters/storage/in-memory"; +import { + handleDeleteMemoryConversation, + handleGetMemoryConversation, + handleGetMemoryWorkingMemory, + handleListMemoryConversationMessages, + handleListMemoryConversations, +} from "./memory.handlers"; + +function createAgentWithMemory(agentId: string, agentName: string, memory: Memory): Agent { + return { + getFullState: () => ({ + id: agentId, + name: agentName, + instructions: "", + status: "idle", + model: "test-model", + tools: [], + subAgents: [], + memory: {}, + }), + getMemory: () => memory, + } as unknown as Agent; +} + +function createDepsWithAgents(agents: Agent[]): ServerProviderDeps { + const logger: Logger = { + trace: vi.fn(), + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + fatal: vi.fn(), + child: vi.fn().mockReturnThis(), + level: "info", + silent: vi.fn(), + } as unknown as Logger; + + return { + agentRegistry: { + getAgent: vi.fn((agentId: string) => + agents.find((agent) => agent.getFullState().id === agentId), + ), + getAllAgents: vi.fn().mockReturnValue(agents), + getAgentCount: vi.fn().mockReturnValue(agents.length), + removeAgent: vi.fn(), + registerAgent: vi.fn(), + getGlobalVoltOpsClient: vi.fn().mockReturnValue(undefined as unknown as VoltOpsClient), + getGlobalLogger: vi.fn().mockReturnValue(logger), + }, + workflowRegistry: { + getWorkflow: vi.fn(), + getWorkflowsForApi: vi.fn().mockReturnValue([]), + getWorkflowDetailForApi: vi.fn(), + getWorkflowCount: vi.fn().mockReturnValue(0), + on: vi.fn(), + off: vi.fn(), + activeExecutions: new Map(), + resumeSuspendedWorkflow: vi.fn(), + }, + triggerRegistry: { + list: vi.fn().mockReturnValue([]), + register: vi.fn(), + registerMany: vi.fn(), + get: vi.fn(), + getByPath: vi.fn(), + unregister: vi.fn(), + clear: vi.fn(), + } as any, + logger, + } as unknown as ServerProviderDeps; +} + +function expectForbidden(result: { success: boolean; httpStatus?: number }) { + expect(result.success).toBe(false); + if (result.success) { + return; + } + expect(result.httpStatus).toBe(403); +} + +describe("memory handlers authorization", () => { + let memory: Memory; + let deps: ServerProviderDeps; + + const agentId = "agent-1"; + const victimUserId = "victim-user"; + const attackerUserId = "attacker-user"; + const victimConversationId = "conv-victim"; + const attackerConversationId = "conv-attacker"; + + beforeEach(async () => { + memory = new Memory({ + storage: new InMemoryStorageAdapter(), + workingMemory: { + enabled: true, + scope: "conversation", + }, + }); + + await memory.createConversation({ + id: victimConversationId, + resourceId: agentId, + userId: victimUserId, + title: "Victim Chat", + metadata: {}, + }); + + await memory.addMessage( + { + id: "victim-msg-1", + role: "user", + parts: [{ type: "text", text: "private message" }], + }, + victimUserId, + victimConversationId, + ); + + await memory.updateWorkingMemory({ + conversationId: victimConversationId, + userId: victimUserId, + content: "Private working memory", + }); + + await memory.createConversation({ + id: attackerConversationId, + resourceId: agentId, + userId: attackerUserId, + title: "Attacker Chat", + metadata: {}, + }); + + const agent = createAgentWithMemory(agentId, "Agent One", memory); + deps = createDepsWithAgents([agent]); + }); + + it("rejects cross-user conversation reads", async () => { + const result = await handleGetMemoryConversation(deps, victimConversationId, { + agentId, + authenticatedUser: { id: attackerUserId }, + }); + + expectForbidden(result); + }); + + it("rejects cross-user message listing without trusting the conversation owner fallback", async () => { + const result = await handleListMemoryConversationMessages(deps, victimConversationId, { + agentId, + authenticatedUser: { id: attackerUserId }, + }); + + expectForbidden(result); + }); + + it("rejects cross-user conversation-scoped working memory reads", async () => { + const result = await handleGetMemoryWorkingMemory(deps, victimConversationId, { + agentId, + authenticatedUser: { id: attackerUserId }, + }); + + expectForbidden(result); + }); + + it("rejects cross-user conversation deletes and leaves the conversation intact", async () => { + const deleteResult = await handleDeleteMemoryConversation(deps, victimConversationId, { + agentId, + authenticatedUser: { id: attackerUserId }, + }); + + expectForbidden(deleteResult); + + const readResult = await handleGetMemoryConversation(deps, victimConversationId, { + agentId, + authenticatedUser: { id: victimUserId }, + }); + + expect(readResult.success).toBe(true); + if (!readResult.success) { + return; + } + expect(readResult.data.conversation.userId).toBe(victimUserId); + }); + + it("scopes conversation lists to the authenticated user", async () => { + const result = await handleListMemoryConversations(deps, { + agentId, + authenticatedUser: { id: attackerUserId }, + }); + + expect(result.success).toBe(true); + if (!result.success) { + return; + } + expect(result.data.conversations).toHaveLength(1); + expect(result.data.conversations[0]?.id).toBe(attackerConversationId); + }); + + it("rejects authenticated requests that specify another userId", async () => { + const result = await handleListMemoryConversations(deps, { + agentId, + userId: victimUserId, + authenticatedUser: { id: attackerUserId }, + }); + + expectForbidden(result); + }); +}); diff --git a/packages/server-core/src/handlers/memory.handlers.ts b/packages/server-core/src/handlers/memory.handlers.ts index 31401f9c1..d23a2945c 100644 --- a/packages/server-core/src/handlers/memory.handlers.ts +++ b/packages/server-core/src/handlers/memory.handlers.ts @@ -12,6 +12,16 @@ import { safeStringify } from "@voltagent/internal"; import { type UIMessage, generateId } from "ai"; import type { ApiResponse } from "../types"; +export type MemoryAuthenticatedUser = { + id?: string | number; + sub?: string | number; + userId?: string | number; +}; + +type MemoryAuthContext = { + authenticatedUser?: MemoryAuthenticatedUser | null; +}; + type MemoryResolution = | { ok: true; @@ -106,6 +116,54 @@ function buildErrorResponse(error: unknown): ApiResponse { }; } +function getAuthenticatedUserId( + authenticatedUser?: MemoryAuthenticatedUser | null, +): string | undefined { + if (!authenticatedUser || typeof authenticatedUser !== "object") { + return undefined; + } + + const id = authenticatedUser.id ?? authenticatedUser.sub ?? authenticatedUser.userId; + if (id === undefined || id === null) { + return undefined; + } + + const normalized = String(id).trim(); + return normalized.length > 0 ? normalized : undefined; +} + +function buildForbiddenResponse(): ApiResponse { + return { + success: false, + error: "Forbidden", + httpStatus: 403, + }; +} + +function validateRequestedUserId( + requestedUserId: string | undefined, + authenticatedUser?: MemoryAuthenticatedUser | null, +): ApiResponse | undefined { + const authenticatedUserId = getAuthenticatedUserId(authenticatedUser); + if (!authenticatedUserId || !requestedUserId) { + return undefined; + } + + return requestedUserId === authenticatedUserId ? undefined : buildForbiddenResponse(); +} + +function authorizeConversation( + conversation: Conversation, + authenticatedUser?: MemoryAuthenticatedUser | null, +): ApiResponse | undefined { + const authenticatedUserId = getAuthenticatedUserId(authenticatedUser); + if (!authenticatedUserId) { + return undefined; + } + + return conversation.userId === authenticatedUserId ? undefined : buildForbiddenResponse(); +} + export async function handleListMemoryConversations( deps: ServerProviderDeps, query: { @@ -116,7 +174,7 @@ export async function handleListMemoryConversations( offset?: number; orderBy?: "created_at" | "updated_at" | "title"; orderDirection?: "ASC" | "DESC"; - }, + } & MemoryAuthContext, ): Promise< ApiResponse<{ conversations: Conversation[]; total: number; limit: number; offset: number }> > { @@ -131,9 +189,15 @@ export async function handleListMemoryConversations( } const resourceId = query.resourceId ?? resolved.resourceId; + const unauthorizedUser = validateRequestedUserId(query.userId, query.authenticatedUser); + if (unauthorizedUser) { + return unauthorizedUser; + } + + const userId = getAuthenticatedUserId(query.authenticatedUser) ?? query.userId; const [conversations, total] = await Promise.all([ resolved.memory.queryConversations({ - userId: query.userId, + userId, resourceId, limit: query.limit, offset: query.offset, @@ -141,7 +205,7 @@ export async function handleListMemoryConversations( orderDirection: query.orderDirection, }), resolved.memory.countConversations({ - userId: query.userId, + userId, resourceId, }), ]); @@ -163,7 +227,7 @@ export async function handleListMemoryConversations( export async function handleGetMemoryConversation( deps: ServerProviderDeps, conversationId: string, - query: { agentId?: string }, + query: { agentId?: string } & MemoryAuthContext, ): Promise> { try { const resolved = resolveMemory(deps, query.agentId); @@ -184,6 +248,11 @@ export async function handleGetMemoryConversation( }; } + const unauthorized = authorizeConversation(conversation, query.authenticatedUser); + if (unauthorized) { + return unauthorized; + } + return { success: true, data: { conversation }, @@ -203,7 +272,7 @@ export async function handleListMemoryConversationMessages( after?: Date; roles?: string[]; userId?: string; - }, + } & MemoryAuthContext, ): Promise> { try { const resolved = resolveMemory(deps, query.agentId); @@ -224,7 +293,15 @@ export async function handleListMemoryConversationMessages( }; } - const userId = query.userId ?? conversation.userId; + const unauthorized = + authorizeConversation(conversation, query.authenticatedUser) ?? + validateRequestedUserId(query.userId, query.authenticatedUser); + if (unauthorized) { + return unauthorized; + } + + const userId = + getAuthenticatedUserId(query.authenticatedUser) ?? query.userId ?? conversation.userId; const messages = await resolved.memory.getMessages(userId, conversationId, { limit: query.limit, before: query.before, @@ -244,7 +321,7 @@ export async function handleListMemoryConversationMessages( export async function handleGetMemoryWorkingMemory( deps: ServerProviderDeps, conversationId: string, - query: { agentId?: string; scope?: "conversation" | "user"; userId?: string }, + query: { agentId?: string; scope?: "conversation" | "user"; userId?: string } & MemoryAuthContext, ): Promise< ApiResponse<{ content: string | null; @@ -265,7 +342,7 @@ export async function handleGetMemoryWorkingMemory( const scope = query.scope === "user" ? "user" : "conversation"; let content: string | null = null; - let userId = query.userId; + let userId = query.userId ?? getAuthenticatedUserId(query.authenticatedUser); if (scope === "conversation") { const conversation = await resolved.memory.getConversation(conversationId); @@ -277,12 +354,24 @@ export async function handleGetMemoryWorkingMemory( }; } + const unauthorized = + authorizeConversation(conversation, query.authenticatedUser) ?? + validateRequestedUserId(query.userId, query.authenticatedUser); + if (unauthorized) { + return unauthorized; + } + userId = userId ?? conversation.userId; content = await resolved.memory.getWorkingMemory({ conversationId, userId, }); } else { + const unauthorized = validateRequestedUserId(query.userId, query.authenticatedUser); + if (unauthorized) { + return unauthorized; + } + if (!userId) { return { success: false, @@ -329,7 +418,7 @@ export async function handleSaveMemoryMessages( userId?: string; conversationId?: string; messages?: SaveMessageEntry[]; - }, + } & MemoryAuthContext, ): Promise> { try { const resolved = resolveMemory(deps, body.agentId); @@ -349,12 +438,14 @@ export async function handleSaveMemoryMessages( }; } + const authenticatedUserId = getAuthenticatedUserId(body.authenticatedUser); + const bodyUserId = body.userId ?? authenticatedUserId; const normalized = body.messages.map((entry) => { const isWrapped = typeof entry === "object" && entry !== null && "message" in entry; const message = isWrapped ? entry.message : (entry as UIMessage); const conversationId = (isWrapped ? entry.conversationId : entry.conversationId) ?? body.conversationId; - const userId = (isWrapped ? entry.userId : entry.userId) ?? body.userId; + const userId = (isWrapped ? entry.userId : entry.userId) ?? bodyUserId; return { message: { @@ -375,6 +466,10 @@ export async function handleSaveMemoryMessages( }; } + if (authenticatedUserId && normalized.some((item) => item.userId !== authenticatedUserId)) { + return buildForbiddenResponse(); + } + const conversationCache = new Map(); for (const item of normalized) { const conversationId = item.conversationId as string; @@ -389,6 +484,14 @@ export async function handleSaveMemoryMessages( } conversationCache.set(conversationId, conversation); } + + const conversation = conversationCache.get(conversationId); + if (conversation) { + const unauthorized = authorizeConversation(conversation, body.authenticatedUser); + if (unauthorized) { + return unauthorized; + } + } } for (const item of normalized) { @@ -437,7 +540,7 @@ export async function handleCreateMemoryConversation( userId?: string; title?: string; metadata?: Record; - }, + } & MemoryAuthContext, ): Promise> { try { const resolved = resolveMemory(deps, body.agentId); @@ -449,7 +552,13 @@ export async function handleCreateMemoryConversation( }; } - if (!body.userId) { + const userId = body.userId ?? getAuthenticatedUserId(body.authenticatedUser); + const unauthorized = validateRequestedUserId(body.userId, body.authenticatedUser); + if (unauthorized) { + return unauthorized; + } + + if (!userId) { return { success: false, error: "userId is required", @@ -470,7 +579,7 @@ export async function handleCreateMemoryConversation( const conversation = await resolved.memory.createConversation({ id: conversationId, resourceId, - userId: body.userId, + userId, title: body.title ?? "", metadata: body.metadata ?? {}, }); @@ -500,7 +609,7 @@ export async function handleUpdateMemoryConversation( userId?: string; title?: string; metadata?: Record; - }, + } & MemoryAuthContext, ): Promise> { try { const resolved = resolveMemory(deps, body.agentId); @@ -512,6 +621,22 @@ export async function handleUpdateMemoryConversation( }; } + const existing = await resolved.memory.getConversation(conversationId); + if (!existing) { + return { + success: false, + error: "Conversation not found", + httpStatus: 404, + }; + } + + const unauthorized = + authorizeConversation(existing, body.authenticatedUser) ?? + validateRequestedUserId(body.userId, body.authenticatedUser); + if (unauthorized) { + return unauthorized; + } + const updates: Partial> = {}; if (body.resourceId !== undefined) { updates.resourceId = body.resourceId; @@ -554,7 +679,7 @@ export async function handleUpdateMemoryConversation( export async function handleDeleteMemoryConversation( deps: ServerProviderDeps, conversationId: string, - query: { agentId?: string }, + query: { agentId?: string } & MemoryAuthContext, ): Promise> { try { const resolved = resolveMemory(deps, query.agentId); @@ -566,6 +691,20 @@ export async function handleDeleteMemoryConversation( }; } + const conversation = await resolved.memory.getConversation(conversationId); + if (!conversation) { + return { + success: false, + error: "Conversation not found", + httpStatus: 404, + }; + } + + const unauthorized = authorizeConversation(conversation, query.authenticatedUser); + if (unauthorized) { + return unauthorized; + } + await resolved.memory.deleteConversation(conversationId); return { success: true, @@ -594,7 +733,7 @@ export async function handleCloneMemoryConversation( title?: string; metadata?: Record; includeMessages?: boolean; - }, + } & MemoryAuthContext, ): Promise> { try { const resolved = resolveMemory(deps, body.agentId); @@ -615,6 +754,13 @@ export async function handleCloneMemoryConversation( }; } + const sourceUnauthorized = + authorizeConversation(source, body.authenticatedUser) ?? + validateRequestedUserId(body.userId, body.authenticatedUser); + if (sourceUnauthorized) { + return sourceUnauthorized; + } + const clonedId = body.newConversationId ?? generateId(); const conversation = await resolved.memory.createConversation({ id: clonedId, @@ -658,7 +804,7 @@ export async function handleUpdateMemoryWorkingMemory( userId?: string; content?: string | Record; mode?: "replace" | "append"; - }, + } & MemoryAuthContext, ): Promise> { try { const resolved = resolveMemory(deps, body.agentId); @@ -687,7 +833,15 @@ export async function handleUpdateMemoryWorkingMemory( }; } - const userId = body.userId ?? conversation.userId; + const unauthorized = + authorizeConversation(conversation, body.authenticatedUser) ?? + validateRequestedUserId(body.userId, body.authenticatedUser); + if (unauthorized) { + return unauthorized; + } + + const userId = + getAuthenticatedUserId(body.authenticatedUser) ?? body.userId ?? conversation.userId; if (body.userId && body.userId !== conversation.userId) { return { success: false, @@ -718,7 +872,7 @@ export async function handleDeleteMemoryMessages( conversationId?: string; userId?: string; messageIds?: string[]; - }, + } & MemoryAuthContext, ): Promise> { try { const resolved = resolveMemory(deps, body.agentId); @@ -738,7 +892,13 @@ export async function handleDeleteMemoryMessages( }; } - if (!body.conversationId || !body.userId) { + const userId = body.userId ?? getAuthenticatedUserId(body.authenticatedUser); + const unauthorizedUser = validateRequestedUserId(body.userId, body.authenticatedUser); + if (unauthorizedUser) { + return unauthorizedUser; + } + + if (!body.conversationId || !userId) { return { success: false, error: "conversationId and userId are required", @@ -754,7 +914,13 @@ export async function handleDeleteMemoryMessages( httpStatus: 404, }; } - if (conversation.userId !== body.userId) { + + const unauthorized = authorizeConversation(conversation, body.authenticatedUser); + if (unauthorized) { + return unauthorized; + } + + if (conversation.userId !== userId) { return { success: false, error: `userId does not match conversation ${conversation.id}`, @@ -762,10 +928,10 @@ export async function handleDeleteMemoryMessages( }; } - const messages = await resolved.memory.getMessages(body.userId, body.conversationId); + const messages = await resolved.memory.getMessages(userId, body.conversationId); const idsToDelete = new Set(body.messageIds); const deleted = messages.filter((message) => idsToDelete.has(message.id)).length; - await resolved.memory.deleteMessages(body.messageIds, body.userId, body.conversationId); + await resolved.memory.deleteMessages(body.messageIds, userId, body.conversationId); return { success: true, data: { deleted }, @@ -784,7 +950,7 @@ export async function handleSearchMemory( threshold?: number; conversationId?: string; userId?: string; - }, + } & MemoryAuthContext, ): Promise> { try { if (!query.searchQuery) { @@ -804,12 +970,35 @@ export async function handleSearchMemory( }; } + const authenticatedUserId = getAuthenticatedUserId(query.authenticatedUser); + const unauthorizedUser = validateRequestedUserId(query.userId, query.authenticatedUser); + if (unauthorizedUser) { + return unauthorizedUser; + } + + if (authenticatedUserId && query.conversationId) { + const conversation = await resolved.memory.getConversation(query.conversationId); + if (!conversation) { + return { + success: false, + error: "Conversation not found", + httpStatus: 404, + }; + } + + const unauthorized = authorizeConversation(conversation, query.authenticatedUser); + if (unauthorized) { + return unauthorized; + } + } + const filter: Record = {}; if (query.conversationId) { filter.conversationId = query.conversationId; } - if (query.userId) { - filter.userId = query.userId; + const userId = authenticatedUserId ?? query.userId; + if (userId) { + filter.userId = userId; } const results = await resolved.memory.searchSimilar(query.searchQuery, { diff --git a/packages/server-elysia/src/routes/memory.routes.ts b/packages/server-elysia/src/routes/memory.routes.ts index 17e92db06..38d6383ca 100644 --- a/packages/server-elysia/src/routes/memory.routes.ts +++ b/packages/server-elysia/src/routes/memory.routes.ts @@ -2,6 +2,7 @@ import type { ServerProviderDeps } from "@voltagent/core"; import type { Logger } from "@voltagent/internal"; import { MEMORY_ROUTES, + type MemoryAuthenticatedUser, handleCloneMemoryConversation, handleCreateMemoryConversation, handleDeleteMemoryConversation, @@ -41,6 +42,13 @@ function parseDate(value?: string): Date | undefined { return Number.isNaN(parsed.getTime()) ? undefined : parsed; } +function getAuthenticatedUser( + store?: Record, +): MemoryAuthenticatedUser | undefined { + const user = store?.authenticatedUser; + return user && typeof user === "object" ? (user as MemoryAuthenticatedUser) : undefined; +} + type MemoryRoutesCompat = typeof MEMORY_ROUTES & { getWorkingMemory?: { path: string }; }; @@ -54,12 +62,13 @@ const memoryWorkingMemoryPath = * Register memory routes */ export function registerMemoryRoutes(app: Elysia, deps: ServerProviderDeps, logger: Logger) { - app.get(MEMORY_ROUTES.listConversations.path, async ({ query, set }) => { + app.get(MEMORY_ROUTES.listConversations.path, async ({ query, set, store }) => { logger.trace("GET /api/memory/conversations - fetching conversations", { query }); const response = await handleListMemoryConversations(deps, { agentId: query.agentId as string | undefined, resourceId: query.resourceId as string | undefined, userId: query.userId as string | undefined, + authenticatedUser: getAuthenticatedUser(store as Record), limit: parseNumber(query.limit as string | number | undefined), offset: parseNumber(query.offset as string | number | undefined), orderBy: query.orderBy as "created_at" | "updated_at" | "title" | undefined, @@ -69,17 +78,18 @@ export function registerMemoryRoutes(app: Elysia, deps: ServerProviderDeps, logg return response; }); - app.get(MEMORY_ROUTES.getConversation.path, async ({ params, query, set }) => { + app.get(MEMORY_ROUTES.getConversation.path, async ({ params, query, set, store }) => { const conversationId = params.conversationId; logger.trace(`GET /api/memory/conversations/${conversationId} - fetching conversation`); const response = await handleGetMemoryConversation(deps, conversationId, { agentId: query.agentId as string | undefined, + authenticatedUser: getAuthenticatedUser(store as Record), }); set.status = response.success ? 200 : (response.httpStatus ?? 500); return response; }); - app.get(MEMORY_ROUTES.listMessages.path, async ({ params, query, set }) => { + app.get(MEMORY_ROUTES.listMessages.path, async ({ params, query, set, store }) => { const conversationId = params.conversationId; logger.trace(`GET /api/memory/conversations/${conversationId}/messages - fetching messages`, { query, @@ -91,12 +101,13 @@ export function registerMemoryRoutes(app: Elysia, deps: ServerProviderDeps, logg after: parseDate(query.after as string | undefined), roles: query.roles ? String(query.roles).split(",") : undefined, userId: query.userId as string | undefined, + authenticatedUser: getAuthenticatedUser(store as Record), }); set.status = response.success ? 200 : (response.httpStatus ?? 500); return response; }); - app.get(memoryWorkingMemoryPath, async ({ params, query, set }) => { + app.get(memoryWorkingMemoryPath, async ({ params, query, set, store }) => { const conversationId = params.conversationId; logger.trace( `GET /api/memory/conversations/${conversationId}/working-memory - fetching working memory`, @@ -106,12 +117,13 @@ export function registerMemoryRoutes(app: Elysia, deps: ServerProviderDeps, logg agentId: query.agentId as string | undefined, scope: query.scope === "user" ? "user" : "conversation", userId: query.userId as string | undefined, + authenticatedUser: getAuthenticatedUser(store as Record), }); set.status = response.success ? 200 : (response.httpStatus ?? 500); return response; }); - app.post(MEMORY_ROUTES.saveMessages.path, async ({ body, query, set }) => { + app.post(MEMORY_ROUTES.saveMessages.path, async ({ body, query, set, store }) => { const payload = body as Record | undefined; logger.trace("POST /api/memory/save-messages - saving messages", { messageCount: Array.isArray(payload?.messages) ? payload?.messages.length : 0, @@ -119,57 +131,62 @@ export function registerMemoryRoutes(app: Elysia, deps: ServerProviderDeps, logg const response = await handleSaveMemoryMessages(deps, { ...(payload ?? {}), agentId: (payload?.agentId as string | undefined) ?? (query.agentId as string | undefined), + authenticatedUser: getAuthenticatedUser(store as Record), }); set.status = response.success ? 200 : (response.httpStatus ?? 500); return response; }); - app.post(MEMORY_ROUTES.createConversation.path, async ({ body, query, set }) => { + app.post(MEMORY_ROUTES.createConversation.path, async ({ body, query, set, store }) => { const payload = body as Record | undefined; logger.trace("POST /api/memory/conversations - creating conversation"); const response = await handleCreateMemoryConversation(deps, { ...(payload ?? {}), agentId: (payload?.agentId as string | undefined) ?? (query.agentId as string | undefined), + authenticatedUser: getAuthenticatedUser(store as Record), }); set.status = response.success ? 200 : (response.httpStatus ?? 500); return response; }); - app.patch(MEMORY_ROUTES.updateConversation.path, async ({ params, body, query, set }) => { + app.patch(MEMORY_ROUTES.updateConversation.path, async ({ params, body, query, set, store }) => { const conversationId = params.conversationId; const payload = body as Record | undefined; logger.trace(`PATCH /api/memory/conversations/${conversationId} - updating conversation`); const response = await handleUpdateMemoryConversation(deps, conversationId, { ...(payload ?? {}), agentId: (payload?.agentId as string | undefined) ?? (query.agentId as string | undefined), + authenticatedUser: getAuthenticatedUser(store as Record), }); set.status = response.success ? 200 : (response.httpStatus ?? 500); return response; }); - app.delete(MEMORY_ROUTES.deleteConversation.path, async ({ params, query, set }) => { + app.delete(MEMORY_ROUTES.deleteConversation.path, async ({ params, query, set, store }) => { const conversationId = params.conversationId; logger.trace(`DELETE /api/memory/conversations/${conversationId} - deleting conversation`); const response = await handleDeleteMemoryConversation(deps, conversationId, { agentId: query.agentId as string | undefined, + authenticatedUser: getAuthenticatedUser(store as Record), }); set.status = response.success ? 200 : (response.httpStatus ?? 500); return response; }); - app.post(MEMORY_ROUTES.cloneConversation.path, async ({ params, body, query, set }) => { + app.post(MEMORY_ROUTES.cloneConversation.path, async ({ params, body, query, set, store }) => { const conversationId = params.conversationId; const payload = body as Record | undefined; logger.trace(`POST /api/memory/conversations/${conversationId}/clone - cloning conversation`); const response = await handleCloneMemoryConversation(deps, conversationId, { ...(payload ?? {}), agentId: (payload?.agentId as string | undefined) ?? (query.agentId as string | undefined), + authenticatedUser: getAuthenticatedUser(store as Record), }); set.status = response.success ? 200 : (response.httpStatus ?? 500); return response; }); - app.post(MEMORY_ROUTES.updateWorkingMemory.path, async ({ params, body, query, set }) => { + app.post(MEMORY_ROUTES.updateWorkingMemory.path, async ({ params, body, query, set, store }) => { const conversationId = params.conversationId; const payload = body as Record | undefined; logger.trace( @@ -178,23 +195,25 @@ export function registerMemoryRoutes(app: Elysia, deps: ServerProviderDeps, logg const response = await handleUpdateMemoryWorkingMemory(deps, conversationId, { ...(payload ?? {}), agentId: (payload?.agentId as string | undefined) ?? (query.agentId as string | undefined), + authenticatedUser: getAuthenticatedUser(store as Record), }); set.status = response.success ? 200 : (response.httpStatus ?? 500); return response; }); - app.post(MEMORY_ROUTES.deleteMessages.path, async ({ body, query, set }) => { + app.post(MEMORY_ROUTES.deleteMessages.path, async ({ body, query, set, store }) => { const payload = body as Record | undefined; logger.trace("POST /api/memory/messages/delete - deleting messages"); const response = await handleDeleteMemoryMessages(deps, { ...(payload ?? {}), agentId: (payload?.agentId as string | undefined) ?? (query.agentId as string | undefined), + authenticatedUser: getAuthenticatedUser(store as Record), }); set.status = response.success ? 200 : (response.httpStatus ?? 500); return response; }); - app.get(MEMORY_ROUTES.searchMemory.path, async ({ query, set }) => { + app.get(MEMORY_ROUTES.searchMemory.path, async ({ query, set, store }) => { logger.trace("GET /api/memory/search - searching memory", { query }); const response = await handleSearchMemory(deps, { agentId: query.agentId as string | undefined, @@ -203,6 +222,7 @@ export function registerMemoryRoutes(app: Elysia, deps: ServerProviderDeps, logg threshold: parseFloatValue(query.threshold as string | number | undefined), conversationId: query.conversationId as string | undefined, userId: query.userId as string | undefined, + authenticatedUser: getAuthenticatedUser(store as Record), }); set.status = response.success ? 200 : (response.httpStatus ?? 500); return response; diff --git a/packages/server-hono/src/routes/memory.routes.ts b/packages/server-hono/src/routes/memory.routes.ts index 44e4ff58e..171014673 100644 --- a/packages/server-hono/src/routes/memory.routes.ts +++ b/packages/server-hono/src/routes/memory.routes.ts @@ -2,6 +2,7 @@ import type { ServerProviderDeps } from "@voltagent/core"; import type { Logger } from "@voltagent/internal"; import { MEMORY_ROUTES, + type MemoryAuthenticatedUser, handleCloneMemoryConversation, handleCreateMemoryConversation, handleDeleteMemoryConversation, @@ -41,6 +42,13 @@ function parseDate(value?: string): Date | undefined { return Number.isNaN(parsed.getTime()) ? undefined : parsed; } +function getAuthenticatedUser(c: { get?: (key: string) => unknown }): + | MemoryAuthenticatedUser + | undefined { + const user = c.get?.("authenticatedUser"); + return user && typeof user === "object" ? (user as MemoryAuthenticatedUser) : undefined; +} + const orderByAllowlist = new Set(["created_at", "updated_at", "title"]); function parseOrderBy(value?: string): "created_at" | "updated_at" | "title" | undefined { @@ -85,6 +93,7 @@ export function registerMemoryRoutes( agentId: query.agentId, resourceId: query.resourceId, userId: query.userId, + authenticatedUser: getAuthenticatedUser(c), limit: parseNumber(query.limit), offset: parseNumber(query.offset), orderBy: parseOrderBy(query.orderBy), @@ -100,6 +109,7 @@ export function registerMemoryRoutes( logger.trace(`GET /api/memory/conversations/${conversationId} - fetching conversation`); const response = await handleGetMemoryConversation(deps, conversationId, { agentId: query.agentId, + authenticatedUser: getAuthenticatedUser(c), }); return c.json(response, response.success ? 200 : (response.httpStatus ?? 500)); }); @@ -117,6 +127,7 @@ export function registerMemoryRoutes( after: parseDate(query.after), roles: query.roles ? query.roles.split(",") : undefined, userId: query.userId, + authenticatedUser: getAuthenticatedUser(c), }); return c.json(response, response.success ? 200 : (response.httpStatus ?? 500)); }); @@ -132,6 +143,7 @@ export function registerMemoryRoutes( agentId: query.agentId, scope: query.scope === "user" ? "user" : "conversation", userId: query.userId, + authenticatedUser: getAuthenticatedUser(c), }); return c.json(response, response.success ? 200 : (response.httpStatus ?? 500)); }); @@ -151,6 +163,7 @@ export function registerMemoryRoutes( const response = await handleSaveMemoryMessages(deps, { ...body, agentId: body?.agentId ?? query.agentId, + authenticatedUser: getAuthenticatedUser(c), }); return c.json(response, response.success ? 200 : (response.httpStatus ?? 500)); }); @@ -168,6 +181,7 @@ export function registerMemoryRoutes( const response = await handleCreateMemoryConversation(deps, { ...body, agentId: body?.agentId ?? query.agentId, + authenticatedUser: getAuthenticatedUser(c), }); return c.json(response, response.success ? 200 : (response.httpStatus ?? 500)); }); @@ -186,6 +200,7 @@ export function registerMemoryRoutes( const response = await handleUpdateMemoryConversation(deps, conversationId, { ...body, agentId: body?.agentId ?? query.agentId, + authenticatedUser: getAuthenticatedUser(c), }); return c.json(response, response.success ? 200 : (response.httpStatus ?? 500)); }); @@ -196,6 +211,7 @@ export function registerMemoryRoutes( logger.trace(`DELETE /api/memory/conversations/${conversationId} - deleting conversation`); const response = await handleDeleteMemoryConversation(deps, conversationId, { agentId: query.agentId, + authenticatedUser: getAuthenticatedUser(c), }); return c.json(response, response.success ? 200 : (response.httpStatus ?? 500)); }); @@ -214,6 +230,7 @@ export function registerMemoryRoutes( const response = await handleCloneMemoryConversation(deps, conversationId, { ...body, agentId: body?.agentId ?? query.agentId, + authenticatedUser: getAuthenticatedUser(c), }); return c.json(response, response.success ? 200 : (response.httpStatus ?? 500)); }); @@ -234,6 +251,7 @@ export function registerMemoryRoutes( const response = await handleUpdateMemoryWorkingMemory(deps, conversationId, { ...body, agentId: body?.agentId ?? query.agentId, + authenticatedUser: getAuthenticatedUser(c), }); return c.json(response, response.success ? 200 : (response.httpStatus ?? 500)); }); @@ -251,6 +269,7 @@ export function registerMemoryRoutes( const response = await handleDeleteMemoryMessages(deps, { ...body, agentId: body?.agentId ?? query.agentId, + authenticatedUser: getAuthenticatedUser(c), }); return c.json(response, response.success ? 200 : (response.httpStatus ?? 500)); }); @@ -265,6 +284,7 @@ export function registerMemoryRoutes( threshold: parseFloatValue(query.threshold), conversationId: query.conversationId, userId: query.userId, + authenticatedUser: getAuthenticatedUser(c), }); return c.json(response, response.success ? 200 : (response.httpStatus ?? 500)); }); diff --git a/packages/serverless-hono/src/routes.ts b/packages/serverless-hono/src/routes.ts index cdc463bba..f2f93da05 100644 --- a/packages/serverless-hono/src/routes.ts +++ b/packages/serverless-hono/src/routes.ts @@ -27,6 +27,7 @@ import { A2A_ROUTES, AGENT_ROUTES, MEMORY_ROUTES, + type MemoryAuthenticatedUser, OBSERVABILITY_MEMORY_ROUTES, OBSERVABILITY_ROUTES, TOOL_ROUTES, @@ -128,6 +129,13 @@ function parseDate(value?: string): Date | undefined { return Number.isNaN(parsed.getTime()) ? undefined : parsed; } +function getAuthenticatedUser(c: { get?: (key: string) => unknown }): + | MemoryAuthenticatedUser + | undefined { + const user = c.get?.("authenticatedUser"); + return user && typeof user === "object" ? (user as MemoryAuthenticatedUser) : undefined; +} + type MemoryRoutesCompat = typeof MEMORY_ROUTES & { getWorkingMemory?: { path: string }; }; @@ -672,6 +680,7 @@ export function registerMemoryRoutes(app: Hono, deps: ServerProviderDeps, logger agentId: query.agentId, resourceId: query.resourceId, userId: query.userId, + authenticatedUser: getAuthenticatedUser(c), limit: parseNumber(query.limit), offset: parseNumber(query.offset), orderBy: query.orderBy as "created_at" | "updated_at" | "title" | undefined, @@ -685,6 +694,7 @@ export function registerMemoryRoutes(app: Hono, deps: ServerProviderDeps, logger const query = c.req.query(); const response = await handleGetMemoryConversation(deps, conversationId, { agentId: query.agentId, + authenticatedUser: getAuthenticatedUser(c), }); return c.json(response, response.success ? 200 : (response.httpStatus ?? 500)); }); @@ -699,6 +709,7 @@ export function registerMemoryRoutes(app: Hono, deps: ServerProviderDeps, logger after: parseDate(query.after), roles: query.roles ? query.roles.split(",") : undefined, userId: query.userId, + authenticatedUser: getAuthenticatedUser(c), }); return c.json(response, response.success ? 200 : (response.httpStatus ?? 500)); }); @@ -710,6 +721,7 @@ export function registerMemoryRoutes(app: Hono, deps: ServerProviderDeps, logger agentId: query.agentId, scope: query.scope === "user" ? "user" : "conversation", userId: query.userId, + authenticatedUser: getAuthenticatedUser(c), }); return c.json(response, response.success ? 200 : (response.httpStatus ?? 500)); }); @@ -723,6 +735,7 @@ export function registerMemoryRoutes(app: Hono, deps: ServerProviderDeps, logger const response = await handleSaveMemoryMessages(deps, { ...body, agentId: (body.agentId as string | undefined) ?? query.agentId, + authenticatedUser: getAuthenticatedUser(c), }); return c.json(response, response.success ? 200 : (response.httpStatus ?? 500)); }); @@ -736,6 +749,7 @@ export function registerMemoryRoutes(app: Hono, deps: ServerProviderDeps, logger const response = await handleCreateMemoryConversation(deps, { ...body, agentId: (body.agentId as string | undefined) ?? query.agentId, + authenticatedUser: getAuthenticatedUser(c), }); return c.json(response, response.success ? 200 : (response.httpStatus ?? 500)); }); @@ -750,6 +764,7 @@ export function registerMemoryRoutes(app: Hono, deps: ServerProviderDeps, logger const response = await handleUpdateMemoryConversation(deps, conversationId, { ...body, agentId: (body.agentId as string | undefined) ?? query.agentId, + authenticatedUser: getAuthenticatedUser(c), }); return c.json(response, response.success ? 200 : (response.httpStatus ?? 500)); }); @@ -759,6 +774,7 @@ export function registerMemoryRoutes(app: Hono, deps: ServerProviderDeps, logger const query = c.req.query(); const response = await handleDeleteMemoryConversation(deps, conversationId, { agentId: query.agentId, + authenticatedUser: getAuthenticatedUser(c), }); return c.json(response, response.success ? 200 : (response.httpStatus ?? 500)); }); @@ -773,6 +789,7 @@ export function registerMemoryRoutes(app: Hono, deps: ServerProviderDeps, logger const response = await handleCloneMemoryConversation(deps, conversationId, { ...body, agentId: (body.agentId as string | undefined) ?? query.agentId, + authenticatedUser: getAuthenticatedUser(c), }); return c.json(response, response.success ? 200 : (response.httpStatus ?? 500)); }); @@ -787,6 +804,7 @@ export function registerMemoryRoutes(app: Hono, deps: ServerProviderDeps, logger const response = await handleUpdateMemoryWorkingMemory(deps, conversationId, { ...body, agentId: (body.agentId as string | undefined) ?? query.agentId, + authenticatedUser: getAuthenticatedUser(c), }); return c.json(response, response.success ? 200 : (response.httpStatus ?? 500)); }); @@ -800,6 +818,7 @@ export function registerMemoryRoutes(app: Hono, deps: ServerProviderDeps, logger const response = await handleDeleteMemoryMessages(deps, { ...body, agentId: (body.agentId as string | undefined) ?? query.agentId, + authenticatedUser: getAuthenticatedUser(c), }); return c.json(response, response.success ? 200 : (response.httpStatus ?? 500)); }); @@ -813,6 +832,7 @@ export function registerMemoryRoutes(app: Hono, deps: ServerProviderDeps, logger threshold: parseFloatValue(query.threshold), conversationId: query.conversationId, userId: query.userId, + authenticatedUser: getAuthenticatedUser(c), }); return c.json(response, response.success ? 200 : (response.httpStatus ?? 500)); });