diff --git a/.changeset/tender-memory-ownership.md b/.changeset/tender-memory-ownership.md new file mode 100644 index 000000000..7ac239f05 --- /dev/null +++ b/.changeset/tender-memory-ownership.md @@ -0,0 +1,13 @@ +--- +"@voltagent/core": patch +"@voltagent/cloudflare-d1": patch +"@voltagent/libsql": patch +"@voltagent/postgres": patch +"@voltagent/server-core": patch +"@voltagent/server-elysia": patch +"@voltagent/server-hono": patch +"@voltagent/supabase": patch +"@voltagent/voltagent-memory": patch +--- + +Harden guarded memory conversation mutations by preserving ownership checks across vector cleanup, D1 deletes, and managed-memory remote mutations. Raise adapter peer dependency minimums to require the core release that exports ownership mismatch errors. diff --git a/packages/cloudflare-d1/package.json b/packages/cloudflare-d1/package.json index c2b2ccb7f..b55956e00 100644 --- a/packages/cloudflare-d1/package.json +++ b/packages/cloudflare-d1/package.json @@ -31,7 +31,7 @@ "main": "dist/index.js", "module": "dist/index.mjs", "peerDependencies": { - "@voltagent/core": "^2.0.0", + "@voltagent/core": "^2.9.1", "@voltagent/logger": "^2.0.0", "ai": "^6.0.0" }, diff --git a/packages/cloudflare-d1/src/memory-adapter.spec.ts b/packages/cloudflare-d1/src/memory-adapter.spec.ts index 36ad60ac6..0c12b0a8b 100644 --- a/packages/cloudflare-d1/src/memory-adapter.spec.ts +++ b/packages/cloudflare-d1/src/memory-adapter.spec.ts @@ -1,4 +1,5 @@ import type { D1Database } from "@cloudflare/workers-types"; +import { ConversationOwnershipMismatchError } from "@voltagent/core"; import { describe, expect, it, vi } from "vitest"; import { D1MemoryAdapter } from "./memory-adapter"; @@ -81,3 +82,79 @@ describe("D1MemoryAdapter queryWorkflowRuns", () => { ]); }); }); + +describe("D1MemoryAdapter conversation ownership guards", () => { + const row = { + id: "conv-1", + resource_id: "agent-1", + user_id: "user-1", + title: "Original", + metadata: "{}", + created_at: "2024-01-01T00:00:00.000Z", + updated_at: "2024-01-01T00:00:00.000Z", + }; + + it("adds expectedUserId to updateConversation mutations", async () => { + vi.spyOn(D1MemoryAdapter.prototype as any, "ensureInitialized").mockResolvedValue(undefined); + const adapter = new D1MemoryAdapter({ binding: createMockBinding(), tablePrefix: "test" }); + vi.spyOn(adapter as any, "all") + .mockResolvedValueOnce([row]) + .mockResolvedValueOnce([{ ...row, title: "Updated" }]); + const runSpy = vi.spyOn(adapter as any, "run").mockResolvedValue({ meta: { changes: 1 } }); + + await (adapter as any).updateConversation( + "conv-1", + { title: "Updated" }, + { expectedUserId: "user-1" }, + ); + + const [sql, args] = runSpy.mock.calls[0]; + expect(sql).toContain("WHERE id = ? AND user_id = ?"); + expect(args).toEqual([expect.any(String), "Updated", "conv-1", "user-1"]); + }); + + it("rejects guarded updates before mutating when the existing owner does not match", async () => { + vi.spyOn(D1MemoryAdapter.prototype as any, "ensureInitialized").mockResolvedValue(undefined); + const adapter = new D1MemoryAdapter({ binding: createMockBinding(), tablePrefix: "test" }); + vi.spyOn(adapter as any, "all").mockResolvedValueOnce([{ ...row, user_id: "user-2" }]); + const runSpy = vi.spyOn(adapter as any, "run").mockResolvedValue({ meta: { changes: 1 } }); + + await expect( + (adapter as any).updateConversation( + "conv-1", + { title: "Updated" }, + { expectedUserId: "user-1" }, + ), + ).rejects.toBeInstanceOf(ConversationOwnershipMismatchError); + + expect(runSpy).not.toHaveBeenCalled(); + }); + + it("uses the guarded parent delete and relies on cascades for owned child rows", async () => { + vi.spyOn(D1MemoryAdapter.prototype as any, "ensureInitialized").mockResolvedValue(undefined); + const adapter = new D1MemoryAdapter({ binding: createMockBinding(), tablePrefix: "test" }); + const runSpy = vi.spyOn(adapter as any, "run").mockResolvedValue({ meta: { changes: 1 } }); + + await (adapter as any).deleteConversation("conv-1", { expectedUserId: "user-1" }); + + expect(runSpy).toHaveBeenCalledTimes(1); + const [sql, args] = runSpy.mock.calls[0]; + expect(sql).toContain("DELETE FROM test_conversations WHERE id = ? AND user_id = ?"); + expect(args).toEqual(["conv-1", "user-1"]); + }); + + it("rejects guarded deletes without deleting child rows when no owned row is affected", async () => { + vi.spyOn(D1MemoryAdapter.prototype as any, "ensureInitialized").mockResolvedValue(undefined); + const adapter = new D1MemoryAdapter({ binding: createMockBinding(), tablePrefix: "test" }); + const runSpy = vi.spyOn(adapter as any, "run").mockResolvedValue({ meta: { changes: 0 } }); + + await expect( + (adapter as any).deleteConversation("conv-1", { expectedUserId: "user-1" }), + ).rejects.toBeInstanceOf(ConversationOwnershipMismatchError); + + expect(runSpy).toHaveBeenCalledTimes(1); + const [sql, args] = runSpy.mock.calls[0]; + expect(sql).toContain("DELETE FROM test_conversations WHERE id = ? AND user_id = ?"); + expect(args).toEqual(["conv-1", "user-1"]); + }); +}); diff --git a/packages/cloudflare-d1/src/memory-adapter.ts b/packages/cloudflare-d1/src/memory-adapter.ts index dd0fe9204..0ad4f79e1 100644 --- a/packages/cloudflare-d1/src/memory-adapter.ts +++ b/packages/cloudflare-d1/src/memory-adapter.ts @@ -8,9 +8,11 @@ import { AgentRegistry, ConversationAlreadyExistsError, ConversationNotFoundError, + ConversationOwnershipMismatchError, } from "@voltagent/core"; import type { Conversation, + ConversationMutationOptions, ConversationQueryOptions, ConversationStepRecord, CreateConversationInput, @@ -102,8 +104,8 @@ export class D1MemoryAdapter implements StorageAdapter { return args.length > 0 ? statement.bind(...args) : statement; } - private async run(sql: string, args: unknown[] = []): Promise { - await this.buildStatement(sql, args).run(); + private async run(sql: string, args: unknown[] = []): Promise<{ meta?: { changes?: number } }> { + return (await this.buildStatement(sql, args).run()) as { meta?: { changes?: number } }; } private async all(sql: string, args: unknown[] = []): Promise { @@ -1037,6 +1039,7 @@ export class D1MemoryAdapter implements StorageAdapter { async updateConversation( id: string, updates: Partial>, + options?: ConversationMutationOptions, ): Promise { await this.ensureInitialized(); @@ -1046,6 +1049,10 @@ export class D1MemoryAdapter implements StorageAdapter { throw new ConversationNotFoundError(id); } + if (options?.expectedUserId !== undefined && conversation.userId !== options.expectedUserId) { + throw new ConversationOwnershipMismatchError(id); + } + const now = new Date().toISOString(); const fieldsToUpdate: string[] = ["updated_at = ?"]; const args: unknown[] = [now]; @@ -1066,12 +1073,21 @@ export class D1MemoryAdapter implements StorageAdapter { } args.push(id); + let whereClause = "WHERE id = ?"; + if (options?.expectedUserId !== undefined) { + whereClause += " AND user_id = ?"; + args.push(options.expectedUserId); + } - await this.run( - `UPDATE ${conversationsTable} SET ${fieldsToUpdate.join(", ")} WHERE id = ?`, + const result = await this.run( + `UPDATE ${conversationsTable} SET ${fieldsToUpdate.join(", ")} ${whereClause}`, args, ); + if (options?.expectedUserId !== undefined && result.meta?.changes === 0) { + throw new ConversationOwnershipMismatchError(id); + } + const updated = await this.getConversation(id); if (!updated) { throw new Error(`Conversation not found after update: ${id}`); @@ -1079,13 +1095,24 @@ export class D1MemoryAdapter implements StorageAdapter { return updated; } - async deleteConversation(id: string): Promise { + async deleteConversation(id: string, options?: ConversationMutationOptions): Promise { await this.ensureInitialized(); const conversationsTable = `${this.tablePrefix}_conversations`; const messagesTable = `${this.tablePrefix}_messages`; const stepsTable = `${this.tablePrefix}_steps`; + if (options?.expectedUserId !== undefined) { + const result = await this.run( + `DELETE FROM ${conversationsTable} WHERE id = ? AND user_id = ?`, + [id, options.expectedUserId], + ); + if (result.meta?.changes === 0) { + throw new ConversationOwnershipMismatchError(id); + } + return; + } + await this.run(`DELETE FROM ${messagesTable} WHERE conversation_id = ?`, [id]); await this.run(`DELETE FROM ${stepsTable} WHERE conversation_id = ?`, [id]); await this.run(`DELETE FROM ${conversationsTable} WHERE id = ?`, [id]); diff --git a/packages/core/src/memory/adapters/storage/in-memory.ts b/packages/core/src/memory/adapters/storage/in-memory.ts index 76dccd800..d19533d1d 100644 --- a/packages/core/src/memory/adapters/storage/in-memory.ts +++ b/packages/core/src/memory/adapters/storage/in-memory.ts @@ -6,9 +6,14 @@ import { deepClone } from "@voltagent/internal/utils"; import type { UIMessage } from "ai"; import type { OperationContext } from "../../../agent/types"; -import { ConversationAlreadyExistsError, ConversationNotFoundError } from "../../errors"; +import { + ConversationAlreadyExistsError, + ConversationNotFoundError, + ConversationOwnershipMismatchError, +} from "../../errors"; import type { Conversation, + ConversationMutationOptions, ConversationQueryOptions, ConversationStepRecord, CreateConversationInput, @@ -424,12 +429,17 @@ export class InMemoryStorageAdapter implements StorageAdapter { async updateConversation( id: string, updates: Partial>, + options?: ConversationMutationOptions, ): Promise { const conversation = this.conversations.get(id); if (!conversation) { throw new ConversationNotFoundError(id); } + if (options?.expectedUserId !== undefined && conversation.userId !== options.expectedUserId) { + throw new ConversationOwnershipMismatchError(id); + } + const updatedConversation: Conversation = { ...conversation, ...updates, @@ -443,12 +453,16 @@ export class InMemoryStorageAdapter implements StorageAdapter { /** * Delete a conversation */ - async deleteConversation(id: string): Promise { + async deleteConversation(id: string, options?: ConversationMutationOptions): Promise { const conversation = this.conversations.get(id); if (!conversation) { throw new ConversationNotFoundError(id); } + if (options?.expectedUserId !== undefined && conversation.userId !== options.expectedUserId) { + throw new ConversationOwnershipMismatchError(id); + } + // Delete conversation this.conversations.delete(id); diff --git a/packages/core/src/memory/errors.ts b/packages/core/src/memory/errors.ts index 853fa4b01..1795aa3d7 100644 --- a/packages/core/src/memory/errors.ts +++ b/packages/core/src/memory/errors.ts @@ -63,6 +63,19 @@ export class ConversationNotFoundError extends MemoryV2Error { } } +/** + * Error thrown when a guarded conversation mutation no longer matches the expected owner + */ +export class ConversationOwnershipMismatchError extends MemoryV2Error { + constructor(conversationId: string) { + super(`Conversation ownership mismatch: ${conversationId}`, "CONVERSATION_OWNERSHIP_MISMATCH", { + conversationId, + }); + this.name = "ConversationOwnershipMismatchError"; + Object.setPrototypeOf(this, ConversationOwnershipMismatchError.prototype); + } +} + /** * Error thrown when trying to create a conversation that already exists */ diff --git a/packages/core/src/memory/index.spec.ts b/packages/core/src/memory/index.spec.ts new file mode 100644 index 000000000..89308a056 --- /dev/null +++ b/packages/core/src/memory/index.spec.ts @@ -0,0 +1,84 @@ +import type { UIMessage } from "ai"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { InMemoryStorageAdapter } from "./adapters/storage/in-memory"; +import { InMemoryVectorAdapter } from "./adapters/vector/in-memory"; +import { Memory } from "./index"; + +describe("Memory conversation mutation guards", () => { + let storage: InMemoryStorageAdapter; + let vector: InMemoryVectorAdapter; + let memory: Memory; + + beforeEach(async () => { + storage = new InMemoryStorageAdapter(); + vector = new InMemoryVectorAdapter(); + memory = new Memory({ + storage, + vector, + }); + + await memory.createConversation({ + id: "conv-1", + userId: "user-1", + resourceId: "agent-1", + title: "Conversation", + metadata: {}, + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("continues unguarded deletes when vector cleanup cannot read the conversation", async () => { + const readError = new Error("read unavailable"); + const getSpy = vi.spyOn(storage, "getConversation").mockRejectedValueOnce(readError); + const deleteSpy = vi.spyOn(storage, "deleteConversation"); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + await expect(memory.deleteConversation("conv-1")).resolves.toBeUndefined(); + + expect(getSpy).toHaveBeenCalledWith("conv-1"); + expect(deleteSpy).toHaveBeenCalledWith("conv-1", undefined); + expect(warnSpy).toHaveBeenCalledWith( + "Failed to delete vectors for conversation conv-1:", + readError, + ); + await expect(storage.getConversation("conv-1")).resolves.toBeNull(); + }); + + it("does not re-read vectors for guarded deletes when ownership lookup finds no conversation", async () => { + const replacementConversation = { + id: "conv-1", + userId: "user-2", + resourceId: "agent-1", + title: "Replacement", + metadata: {}, + createdAt: "2024-01-01T00:00:00.000Z", + updatedAt: "2024-01-01T00:00:00.000Z", + }; + const deleteError = new Error("missing conversation"); + const getSpy = vi + .spyOn(storage, "getConversation") + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(replacementConversation); + const secretMessage: UIMessage<{ createdAt: Date }> = { + id: "msg-1", + role: "user", + parts: [{ type: "text", text: "secret" }], + metadata: { createdAt: new Date() }, + }; + const getMessagesSpy = vi.spyOn(storage, "getMessages").mockResolvedValueOnce([secretMessage]); + const deleteBatchSpy = vi.spyOn(vector, "deleteBatch"); + const deleteSpy = vi.spyOn(storage, "deleteConversation").mockRejectedValueOnce(deleteError); + + await expect(memory.deleteConversation("conv-1", { expectedUserId: "user-1" })).rejects.toThrow( + deleteError, + ); + + expect(getSpy).toHaveBeenCalledTimes(1); + expect(deleteSpy).toHaveBeenCalledWith("conv-1", { expectedUserId: "user-1" }); + expect(getMessagesSpy).not.toHaveBeenCalled(); + expect(deleteBatchSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/core/src/memory/index.ts b/packages/core/src/memory/index.ts index a63206f2f..1e0ee7453 100644 --- a/packages/core/src/memory/index.ts +++ b/packages/core/src/memory/index.ts @@ -7,9 +7,14 @@ import type { UIMessage } from "ai"; import type { z } from "zod"; import type { OperationContext } from "../agent/types"; import { AiSdkEmbeddingAdapter } from "./adapters/embedding/ai-sdk"; -import { EmbeddingAdapterNotConfiguredError, VectorAdapterNotConfiguredError } from "./errors"; +import { + ConversationOwnershipMismatchError, + EmbeddingAdapterNotConfiguredError, + VectorAdapterNotConfiguredError, +} from "./errors"; import type { Conversation, + ConversationMutationOptions, ConversationQueryOptions, ConversationStepRecord, CreateConversationInput, @@ -280,19 +285,34 @@ export class Memory { async updateConversation( id: string, updates: Partial>, + options?: ConversationMutationOptions, ): Promise { - return this.storage.updateConversation(id, updates); + return this.storage.updateConversation(id, updates, options); } /** * Delete a conversation */ - async deleteConversation(id: string): Promise { - // If vector adapter is configured, delete associated vectors - if (this.vector) { + async deleteConversation(id: string, options?: ConversationMutationOptions): Promise { + let conversation: Conversation | null = null; + + if (options?.expectedUserId !== undefined) { + conversation = await this.storage.getConversation(id); + + if (conversation && conversation.userId !== options.expectedUserId) { + throw new ConversationOwnershipMismatchError(id); + } + } + + // If vector adapter is configured, delete associated vectors. + // For guarded deletes, do not re-fetch when the ownership lookup found no + // conversation: a same-ID conversation may have been recreated for another + // owner before the guarded storage mutation runs. + const canCleanVectors = options?.expectedUserId === undefined || conversation !== null; + if (this.vector && canCleanVectors) { try { - // Try to get the conversation first to get userId - const conversation = await this.storage.getConversation(id); + conversation ??= await this.storage.getConversation(id); + if (conversation) { // Get all messages to find vector IDs const messages = await this.storage.getMessages(conversation.userId, id); @@ -307,7 +327,7 @@ export class Memory { } } - return this.storage.deleteConversation(id); + return this.storage.deleteConversation(id, options); } // ============================================================================ diff --git a/packages/core/src/memory/types.ts b/packages/core/src/memory/types.ts index 3f595cd07..ba47528fa 100644 --- a/packages/core/src/memory/types.ts +++ b/packages/core/src/memory/types.ts @@ -58,6 +58,10 @@ export type ConversationQueryOptions = { orderDirection?: "ASC" | "DESC"; }; +export type ConversationMutationOptions = { + expectedUserId?: string; +}; + /** * Options for getting messages */ @@ -443,8 +447,9 @@ export interface StorageAdapter { updateConversation( id: string, updates: Partial>, + options?: ConversationMutationOptions, ): Promise; - deleteConversation(id: string): Promise; + deleteConversation(id: string, options?: ConversationMutationOptions): Promise; saveConversationSteps?(steps: ConversationStepRecord[]): Promise; getConversationSteps?( diff --git a/packages/core/src/voltops/client.ts b/packages/core/src/voltops/client.ts index 995885088..64c01864f 100644 --- a/packages/core/src/voltops/client.ts +++ b/packages/core/src/voltops/client.ts @@ -13,6 +13,7 @@ import { ResourceType, buildLogContext, buildVoltOpsLogMessage } from "../logger import type { SearchResult, VectorItem } from "../memory/adapters/vector/types"; import type { Conversation, + ConversationMutationOptions, ConversationQueryOptions, ConversationStepRecord, CreateConversationInput, @@ -467,8 +468,8 @@ export class VoltOpsClient implements IVoltOpsClient { this.getManagedMemoryConversation(databaseId, conversationId), query: (databaseId, options) => this.queryManagedMemoryConversations(databaseId, options), update: (databaseId, input) => this.updateManagedMemoryConversation(databaseId, input), - delete: (databaseId, conversationId) => - this.deleteManagedMemoryConversation(databaseId, conversationId), + delete: (databaseId, conversationId, options) => + this.deleteManagedMemoryConversation(databaseId, conversationId, options), }, workingMemory: { get: (databaseId, input) => this.getManagedMemoryWorkingMemory(databaseId, input), @@ -817,13 +818,18 @@ export class VoltOpsClient implements IVoltOpsClient { databaseId: string, input: ManagedMemoryUpdateConversationInput, ): Promise { + const body = { + updates: input.updates, + ...(input.expectedUserId !== undefined ? { expectedUserId: input.expectedUserId } : {}), + }; + const payload = await this.request<{ success: boolean; data?: { conversation?: Conversation }; }>( "PATCH", `/managed-memory/projects/databases/${databaseId}/conversations/${input.conversationId}`, - { updates: input.updates }, + body, ); if (!payload?.success || !payload.data?.conversation) { @@ -836,10 +842,16 @@ export class VoltOpsClient implements IVoltOpsClient { private async deleteManagedMemoryConversation( databaseId: string, conversationId: string, + options?: ConversationMutationOptions, ): Promise { + const body = + options?.expectedUserId !== undefined + ? { expectedUserId: options.expectedUserId } + : undefined; const payload = await this.request<{ success: boolean }>( "DELETE", `/managed-memory/projects/databases/${databaseId}/conversations/${conversationId}`, + body, ); if (!payload?.success) { diff --git a/packages/core/src/voltops/types.ts b/packages/core/src/voltops/types.ts index 8eb0608ee..03788e28b 100644 --- a/packages/core/src/voltops/types.ts +++ b/packages/core/src/voltops/types.ts @@ -12,6 +12,7 @@ import type { BaseMessage } from "../agent/providers/base/types"; import type { SearchResult, VectorItem } from "../memory/adapters/vector/types"; import type { Conversation, + ConversationMutationOptions, ConversationQueryOptions, ConversationStepRecord, CreateConversationInput, @@ -1239,6 +1240,7 @@ export interface ManagedMemoryDeleteVectorsInput { export interface ManagedMemoryUpdateConversationInput { conversationId: string; updates: Partial>; + expectedUserId?: string; } export interface ManagedMemoryWorkingMemoryInput { @@ -1280,7 +1282,11 @@ export interface ManagedMemoryConversationsClient { get(databaseId: string, conversationId: string): Promise; query(databaseId: string, options: ConversationQueryOptions): Promise; update(databaseId: string, input: ManagedMemoryUpdateConversationInput): Promise; - delete(databaseId: string, conversationId: string): Promise; + delete( + databaseId: string, + conversationId: string, + options?: ConversationMutationOptions, + ): Promise; } export interface ManagedMemoryWorkingMemoryClient { diff --git a/packages/libsql/package.json b/packages/libsql/package.json index 0e2e1bf37..3c79fc44f 100644 --- a/packages/libsql/package.json +++ b/packages/libsql/package.json @@ -41,7 +41,7 @@ "main": "dist/index.js", "module": "dist/index.mjs", "peerDependencies": { - "@voltagent/core": "^2.0.0", + "@voltagent/core": "^2.9.1", "@voltagent/logger": "^2.0.0", "ai": "^6.0.0" }, diff --git a/packages/libsql/src/memory-core.ts b/packages/libsql/src/memory-core.ts index c2695a03b..99cf176ee 100644 --- a/packages/libsql/src/memory-core.ts +++ b/packages/libsql/src/memory-core.ts @@ -5,9 +5,14 @@ */ import type { Client } from "@libsql/client"; -import { ConversationAlreadyExistsError, ConversationNotFoundError } from "@voltagent/core"; +import { + ConversationAlreadyExistsError, + ConversationNotFoundError, + ConversationOwnershipMismatchError, +} from "@voltagent/core"; import type { Conversation, + ConversationMutationOptions, ConversationQueryOptions, ConversationStepRecord, CreateConversationInput, @@ -960,6 +965,7 @@ export class LibSQLMemoryCore implements StorageAdapter { async updateConversation( id: string, updates: Partial>, + options?: ConversationMutationOptions, ): Promise { await this.initialize(); @@ -969,6 +975,10 @@ export class LibSQLMemoryCore implements StorageAdapter { throw new ConversationNotFoundError(id); } + if (options?.expectedUserId !== undefined && conversation.userId !== options.expectedUserId) { + throw new ConversationOwnershipMismatchError(id); + } + const now = new Date().toISOString(); const fieldsToUpdate: string[] = ["updated_at = ?"]; const args: any[] = [now]; @@ -989,12 +999,21 @@ export class LibSQLMemoryCore implements StorageAdapter { } args.push(id); + let whereClause = "WHERE id = ?"; + if (options?.expectedUserId !== undefined) { + whereClause += " AND user_id = ?"; + args.push(options.expectedUserId); + } - await this.client.execute({ - sql: `UPDATE ${conversationsTable} SET ${fieldsToUpdate.join(", ")} WHERE id = ?`, + const result = await this.client.execute({ + sql: `UPDATE ${conversationsTable} SET ${fieldsToUpdate.join(", ")} ${whereClause}`, args, }); + if (options?.expectedUserId !== undefined && result.rowsAffected === 0) { + throw new ConversationOwnershipMismatchError(id); + } + const updated = await this.getConversation(id); if (!updated) { throw new Error(`Conversation not found after update: ${id}`); @@ -1002,15 +1021,26 @@ export class LibSQLMemoryCore implements StorageAdapter { return updated; } - async deleteConversation(id: string): Promise { + async deleteConversation(id: string, options?: ConversationMutationOptions): Promise { await this.initialize(); const conversationsTable = `${this.tablePrefix}_conversations`; - await this.client.execute({ - sql: `DELETE FROM ${conversationsTable} WHERE id = ?`, - args: [id], + const args: any[] = [id]; + let whereClause = "WHERE id = ?"; + if (options?.expectedUserId !== undefined) { + whereClause += " AND user_id = ?"; + args.push(options.expectedUserId); + } + + const result = await this.client.execute({ + sql: `DELETE FROM ${conversationsTable} ${whereClause}`, + args, }); + + if (options?.expectedUserId !== undefined && result.rowsAffected === 0) { + throw new ConversationOwnershipMismatchError(id); + } } // ============================================================================ diff --git a/packages/libsql/src/memory-v2-adapter.spec.ts b/packages/libsql/src/memory-v2-adapter.spec.ts index 2e2a429b9..c63bcdd01 100644 --- a/packages/libsql/src/memory-v2-adapter.spec.ts +++ b/packages/libsql/src/memory-v2-adapter.spec.ts @@ -3,6 +3,7 @@ * Tests query shapes with mocked client */ +import { ConversationOwnershipMismatchError } from "@voltagent/core"; import type { UIMessage } from "ai"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { LibSQLMemoryAdapter } from "./memory-v2-adapter"; @@ -135,4 +136,83 @@ describe.sequential("LibSQLMemoryAdapter - Advanced Behavior", () => { 5, ]); }); + + it("adds expectedUserId to updateConversation mutations", async () => { + const existing = { + id: "conv-1", + resource_id: "agent-1", + user_id: "user-1", + title: "Original", + metadata: "{}", + created_at: "2024-01-01T00:00:00.000Z", + updated_at: "2024-01-01T00:00:00.000Z", + }; + + mockExecute + .mockResolvedValueOnce({ rows: [existing] }) + .mockResolvedValueOnce({ rows: [], rowsAffected: 1 }) + .mockResolvedValueOnce({ rows: [{ ...existing, title: "Updated" }] }); + + await (adapter as any).updateConversation( + "conv-1", + { title: "Updated" }, + { expectedUserId: "user-1" }, + ); + + const updateCall = mockExecute.mock.calls.find(([input]) => + String(input.sql).includes("UPDATE test_conversations"), + )?.[0]; + expect(updateCall.sql).toContain("WHERE id = ? AND user_id = ?"); + expect(updateCall.args).toEqual([expect.any(String), "Updated", "conv-1", "user-1"]); + }); + + it("rejects guarded updates when the existing owner does not match", async () => { + const existing = { + id: "conv-1", + resource_id: "agent-1", + user_id: "user-2", + title: "Original", + metadata: "{}", + created_at: "2024-01-01T00:00:00.000Z", + updated_at: "2024-01-01T00:00:00.000Z", + }; + + mockExecute.mockResolvedValueOnce({ rows: [existing] }); + + await expect( + (adapter as any).updateConversation( + "conv-1", + { title: "Updated" }, + { expectedUserId: "user-1" }, + ), + ).rejects.toBeInstanceOf(ConversationOwnershipMismatchError); + + expect( + mockExecute.mock.calls.some(([input]) => + String(input.sql).includes("UPDATE test_conversations"), + ), + ).toBe(false); + }); + + it("adds expectedUserId to deleteConversation mutations", async () => { + mockExecute.mockResolvedValueOnce({ rows: [], rowsAffected: 1 }); + + await (adapter as any).deleteConversation("conv-1", { expectedUserId: "user-1" }); + + const deleteCall = mockExecute.mock.calls.at(-1)?.[0]; + expect(deleteCall.sql).toContain("DELETE FROM test_conversations WHERE id = ? AND user_id = ?"); + expect(deleteCall.args).toEqual(["conv-1", "user-1"]); + }); + + it("rejects guarded deletes when no owned conversation row is affected", async () => { + mockExecute.mockResolvedValueOnce({ rows: [], rowsAffected: 0 }); + + await expect( + (adapter as any).deleteConversation("conv-1", { expectedUserId: "user-1" }), + ).rejects.toBeInstanceOf(ConversationOwnershipMismatchError); + + const deleteCall = mockExecute.mock.calls.at(-1)?.[0]; + expect(deleteCall.sql).toContain("DELETE FROM test_conversations WHERE id = ? AND user_id = ?"); + expect(deleteCall.args).toEqual(["conv-1", "user-1"]); + }); }); diff --git a/packages/postgres/package.json b/packages/postgres/package.json index a17bc179c..29d39ea3e 100644 --- a/packages/postgres/package.json +++ b/packages/postgres/package.json @@ -31,7 +31,7 @@ "main": "dist/index.js", "module": "dist/index.mjs", "peerDependencies": { - "@voltagent/core": "^2.0.0", + "@voltagent/core": "^2.9.1", "ai": "^6.0.0" }, "repository": { diff --git a/packages/postgres/src/memory-adapter.spec.ts b/packages/postgres/src/memory-adapter.spec.ts index 0b7a12b0a..d08d4f8aa 100644 --- a/packages/postgres/src/memory-adapter.spec.ts +++ b/packages/postgres/src/memory-adapter.spec.ts @@ -268,6 +268,38 @@ describe.sequential("PostgreSQLMemoryAdapter - Core Functionality", () => { const deleted = await adapter.getConversation("conv-delete"); expect(deleted).toBeNull(); }); + + it("adds expectedUserId to updateConversation mutations", async () => { + const originalData = createConversationData({ id: "conv-guard", title: "Original" }); + const tx = mockTransaction(); + mockGetConversation(originalData); + mockResultWith({ ...originalData, title: "Updated" }); + tx.commit(); + + await (adapter as any).updateConversation( + "conv-guard", + { title: "Updated" }, + { expectedUserId: "user-1" }, + ); + + const updateCall = mockQuery.mock.calls.find(([sql]) => + String(sql).includes("UPDATE test_conversations"), + ); + expect(updateCall?.[0]).toContain("WHERE id = $3 AND user_id = $4"); + expect(updateCall?.[1]).toEqual([expect.any(String), "Updated", "conv-guard", "user-1"]); + }); + + it("adds expectedUserId to deleteConversation mutations", async () => { + mockQuery.mockResolvedValueOnce({ rows: [{ id: "conv-delete" }], rowCount: 1 }); + + await (adapter as any).deleteConversation("conv-delete", { expectedUserId: "user-1" }); + + const deleteCall = mockQuery.mock.calls.find(([sql]) => + String(sql).includes("DELETE FROM test_conversations"), + ); + expect(deleteCall?.[0]).toContain("WHERE id = $1 AND user_id = $2"); + expect(deleteCall?.[1]).toEqual(["conv-delete", "user-1"]); + }); }); // ============================================================================ diff --git a/packages/postgres/src/memory-adapter.ts b/packages/postgres/src/memory-adapter.ts index 6e476c330..b85a0a40e 100644 --- a/packages/postgres/src/memory-adapter.ts +++ b/packages/postgres/src/memory-adapter.ts @@ -5,9 +5,14 @@ */ import type { ConnectionOptions } from "node:tls"; -import { ConversationAlreadyExistsError, ConversationNotFoundError } from "@voltagent/core"; +import { + ConversationAlreadyExistsError, + ConversationNotFoundError, + ConversationOwnershipMismatchError, +} from "@voltagent/core"; import type { Conversation, + ConversationMutationOptions, ConversationQueryOptions, ConversationStepRecord, CreateConversationInput, @@ -1048,6 +1053,7 @@ export class PostgreSQLMemoryAdapter implements StorageAdapter { async updateConversation( id: string, updates: Partial>, + options?: ConversationMutationOptions, ): Promise { await this.initPromise; @@ -1061,6 +1067,10 @@ export class PostgreSQLMemoryAdapter implements StorageAdapter { throw new ConversationNotFoundError(id); } + if (options?.expectedUserId !== undefined && conversation.userId !== options.expectedUserId) { + throw new ConversationOwnershipMismatchError(id); + } + const now = new Date().toISOString(); const fieldsToUpdate: string[] = ["updated_at = $1"]; const params: any[] = [now]; @@ -1085,15 +1095,28 @@ export class PostgreSQLMemoryAdapter implements StorageAdapter { } params.push(id); // WHERE clause + const idParam = paramCount; + paramCount++; + + let ownerClause = ""; + if (options?.expectedUserId !== undefined) { + ownerClause = ` AND user_id = $${paramCount}`; + params.push(options.expectedUserId); + paramCount++; + } const result = await client.query( `UPDATE ${conversationsTable} SET ${fieldsToUpdate.join(", ")} - WHERE id = $${paramCount} + WHERE id = $${idParam}${ownerClause} RETURNING *`, params, ); + if (options?.expectedUserId !== undefined && result.rows.length === 0) { + throw new ConversationOwnershipMismatchError(id); + } + await client.query("COMMIT"); const row = result.rows[0]; @@ -1117,14 +1140,28 @@ export class PostgreSQLMemoryAdapter implements StorageAdapter { /** * Delete a conversation */ - async deleteConversation(id: string): Promise { + async deleteConversation(id: string, options?: ConversationMutationOptions): Promise { await this.initPromise; const client = await this.pool.connect(); try { const conversationsTable = this.getTableName(`${this.tablePrefix}_conversations`); - await client.query(`DELETE FROM ${conversationsTable} WHERE id = $1`, [id]); + const params: any[] = [id]; + let ownerClause = ""; + if (options?.expectedUserId !== undefined) { + ownerClause = " AND user_id = $2"; + params.push(options.expectedUserId); + } + + const result = await client.query( + `DELETE FROM ${conversationsTable} WHERE id = $1${ownerClause} RETURNING id`, + params, + ); + + if (options?.expectedUserId !== undefined && result.rows.length === 0) { + throw new ConversationOwnershipMismatchError(id); + } this.log(`Deleted conversation ${id}`); } finally { diff --git a/packages/server-core/package.json b/packages/server-core/package.json index a6e0f4d68..e9a84b899 100644 --- a/packages/server-core/package.json +++ b/packages/server-core/package.json @@ -4,7 +4,7 @@ "version": "2.1.19", "dependencies": { "@modelcontextprotocol/sdk": "^1.12.1", - "@voltagent/core": "^2.8.1", + "@voltagent/core": "^2.9.1", "@voltagent/internal": "^1.0.2", "ai": "^6.0.0", "jsonwebtoken": "^9.0.2", @@ -46,7 +46,7 @@ "main": "dist/index.js", "module": "dist/index.mjs", "peerDependencies": { - "@voltagent/core": "^2.0.0", + "@voltagent/core": "^2.9.1", "zod": "^3.25.0 || ^4.0.0" }, "repository": { 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..2e05ea84f --- /dev/null +++ b/packages/server-core/src/handlers/memory.handlers.spec.ts @@ -0,0 +1,250 @@ +import { + ConversationOwnershipMismatchError, + InMemoryStorageAdapter, + Memory, +} from "@voltagent/core"; +import type { Agent, Logger, ServerProviderDeps, VoltOpsClient } from "@voltagent/core"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + handleDeleteMemoryConversation, + handleGetMemoryConversation, + handleListMemoryConversationMessages, + handleListMemoryConversations, + handleUpdateMemoryConversation, +} 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; +} + +describe("memory handlers ownership checks", () => { + let memory: Memory; + let deps: ServerProviderDeps; + const agentId = "agent-1"; + const ownerUserId = "user-alice"; + const otherUserId = "user-bob"; + const conversationId = "conv-private"; + const otherConversationId = "conv-bob"; + + beforeEach(async () => { + memory = new Memory({ + storage: new InMemoryStorageAdapter(), + }); + + await memory.createConversation({ + id: conversationId, + resourceId: agentId, + userId: ownerUserId, + title: "Alice Private", + metadata: {}, + }); + + await memory.createConversation({ + id: otherConversationId, + resourceId: agentId, + userId: otherUserId, + title: "Bob Private", + metadata: {}, + }); + + await memory.addMessage( + { + id: "msg-1", + role: "user", + parts: [{ type: "text", text: "Confidential" }], + }, + ownerUserId, + conversationId, + ); + + deps = createDepsWithAgents([createAgentWithMemory(agentId, "Agent One", memory)]); + }); + + it("rejects reading a conversation owned by a different authenticated user", async () => { + const result = await handleGetMemoryConversation(deps, conversationId, { + agentId, + requestingUserId: otherUserId, + }); + + expect(result.success).toBe(false); + expect(result.httpStatus).toBe(403); + }); + + it("rejects reading a conversation when the authenticated identity is empty", async () => { + const result = await handleGetMemoryConversation(deps, conversationId, { + agentId, + requestingUserId: "", + }); + + expect(result.success).toBe(false); + expect(result.httpStatus).toBe(403); + }); + + it("lists conversations for the authenticated user instead of a client-supplied userId", async () => { + const result = await handleListMemoryConversations(deps, { + agentId, + userId: ownerUserId, + requestingUserId: otherUserId, + }); + + expect(result.success).toBe(true); + if (!result.success) return; + expect(result.data.total).toBe(1); + expect(result.data.conversations).toHaveLength(1); + expect(result.data.conversations[0]?.id).toBe(otherConversationId); + expect(result.data.conversations[0]?.userId).toBe(otherUserId); + }); + + it("rejects listing conversations when the authenticated identity is empty", async () => { + const result = await handleListMemoryConversations(deps, { + agentId, + userId: ownerUserId, + requestingUserId: "", + }); + + expect(result.success).toBe(false); + expect(result.httpStatus).toBe(403); + }); + + it("rejects listing messages for a conversation owned by a different authenticated user", async () => { + const result = await handleListMemoryConversationMessages(deps, conversationId, { + agentId, + requestingUserId: otherUserId, + }); + + expect(result.success).toBe(false); + expect(result.httpStatus).toBe(403); + }); + + it("rejects updating a conversation owned by a different authenticated user", async () => { + const result = await handleUpdateMemoryConversation(deps, conversationId, { + agentId, + requestingUserId: otherUserId, + title: "Bob title", + } as any); + + expect(result.success).toBe(false); + expect(result.httpStatus).toBe(403); + + const conversation = await memory.getConversation(conversationId); + expect(conversation?.title).toBe("Alice Private"); + }); + + it("returns forbidden when a guarded storage update no longer affects the owner", async () => { + const updateSpy = vi + .spyOn(memory, "updateConversation") + .mockRejectedValueOnce(new ConversationOwnershipMismatchError(conversationId)); + + const result = await handleUpdateMemoryConversation(deps, conversationId, { + agentId, + requestingUserId: ownerUserId, + title: "Updated title", + }); + + expect(result.success).toBe(false); + expect(result.httpStatus).toBe(403); + expect(updateSpy).toHaveBeenCalledWith( + conversationId, + { title: "Updated title" }, + { expectedUserId: ownerUserId }, + ); + }); + + it("rejects deleting a conversation owned by a different authenticated user", async () => { + const result = await handleDeleteMemoryConversation(deps, conversationId, { + agentId, + requestingUserId: otherUserId, + }); + + expect(result.success).toBe(false); + expect(result.httpStatus).toBe(403); + + await expect(memory.getConversation(conversationId)).resolves.not.toBeNull(); + }); + + it("returns forbidden when a guarded storage delete no longer affects the owner", async () => { + const deleteSpy = vi + .spyOn(memory, "deleteConversation") + .mockRejectedValueOnce(new ConversationOwnershipMismatchError(conversationId)); + + const result = await handleDeleteMemoryConversation(deps, conversationId, { + agentId, + requestingUserId: ownerUserId, + }); + + expect(result.success).toBe(false); + expect(result.httpStatus).toBe(403); + expect(deleteSpy).toHaveBeenCalledWith(conversationId, { expectedUserId: ownerUserId }); + }); + + it("allows the owning authenticated user to manage the conversation", async () => { + const result = await handleGetMemoryConversation(deps, conversationId, { + agentId, + requestingUserId: ownerUserId, + }); + + expect(result.success).toBe(true); + if (!result.success) return; + expect(result.data.conversation.userId).toBe(ownerUserId); + }); +}); diff --git a/packages/server-core/src/handlers/memory.handlers.ts b/packages/server-core/src/handlers/memory.handlers.ts index 31401f9c1..b58308a59 100644 --- a/packages/server-core/src/handlers/memory.handlers.ts +++ b/packages/server-core/src/handlers/memory.handlers.ts @@ -3,6 +3,7 @@ import { type Conversation, ConversationAlreadyExistsError, ConversationNotFoundError, + ConversationOwnershipMismatchError, EmbeddingAdapterNotConfiguredError, type Memory, type ServerProviderDeps, @@ -106,6 +107,25 @@ function buildErrorResponse(error: unknown): ApiResponse { }; } +function buildForbiddenResponse(): ApiResponse { + return { + success: false, + error: "Forbidden", + httpStatus: 403, + }; +} + +function assertConversationOwner( + conversation: Conversation, + requestingUserId?: string, +): ApiResponse | null { + if (requestingUserId !== undefined && conversation.userId !== requestingUserId) { + return buildForbiddenResponse(); + } + + return null; +} + export async function handleListMemoryConversations( deps: ServerProviderDeps, query: { @@ -116,6 +136,7 @@ export async function handleListMemoryConversations( offset?: number; orderBy?: "created_at" | "updated_at" | "title"; orderDirection?: "ASC" | "DESC"; + requestingUserId?: string; }, ): Promise< ApiResponse<{ conversations: Conversation[]; total: number; limit: number; offset: number }> @@ -130,10 +151,15 @@ export async function handleListMemoryConversations( }; } + if (query.requestingUserId !== undefined && !query.requestingUserId) { + return buildForbiddenResponse(); + } + const resourceId = query.resourceId ?? resolved.resourceId; + const userId = query.requestingUserId ?? query.userId; const [conversations, total] = await Promise.all([ resolved.memory.queryConversations({ - userId: query.userId, + userId, resourceId, limit: query.limit, offset: query.offset, @@ -141,7 +167,7 @@ export async function handleListMemoryConversations( orderDirection: query.orderDirection, }), resolved.memory.countConversations({ - userId: query.userId, + userId, resourceId, }), ]); @@ -163,7 +189,7 @@ export async function handleListMemoryConversations( export async function handleGetMemoryConversation( deps: ServerProviderDeps, conversationId: string, - query: { agentId?: string }, + query: { agentId?: string; requestingUserId?: string }, ): Promise> { try { const resolved = resolveMemory(deps, query.agentId); @@ -184,6 +210,11 @@ export async function handleGetMemoryConversation( }; } + const ownershipError = assertConversationOwner(conversation, query.requestingUserId); + if (ownershipError) { + return ownershipError; + } + return { success: true, data: { conversation }, @@ -203,6 +234,7 @@ export async function handleListMemoryConversationMessages( after?: Date; roles?: string[]; userId?: string; + requestingUserId?: string; }, ): Promise> { try { @@ -224,6 +256,11 @@ export async function handleListMemoryConversationMessages( }; } + const ownershipError = assertConversationOwner(conversation, query.requestingUserId); + if (ownershipError) { + return ownershipError; + } + const userId = query.userId ?? conversation.userId; const messages = await resolved.memory.getMessages(userId, conversationId, { limit: query.limit, @@ -498,6 +535,7 @@ export async function handleUpdateMemoryConversation( agentId?: string; resourceId?: string; userId?: string; + requestingUserId?: string; title?: string; metadata?: Record; }, @@ -534,7 +572,27 @@ export async function handleUpdateMemoryConversation( }; } - const conversation = await resolved.memory.updateConversation(conversationId, updates); + const existingConversation = await resolved.memory.getConversation(conversationId); + if (!existingConversation) { + return { + success: false, + error: "Conversation not found", + httpStatus: 404, + }; + } + + const ownershipError = assertConversationOwner(existingConversation, body.requestingUserId); + if (ownershipError) { + return ownershipError; + } + + const mutationOptions = + body.requestingUserId !== undefined ? { expectedUserId: body.requestingUserId } : undefined; + const conversation = await resolved.memory.updateConversation( + conversationId, + updates, + mutationOptions, + ); return { success: true, data: { conversation }, @@ -547,6 +605,9 @@ export async function handleUpdateMemoryConversation( httpStatus: 404, }; } + if (error instanceof ConversationOwnershipMismatchError) { + return buildForbiddenResponse(); + } return buildErrorResponse(error); } } @@ -554,7 +615,7 @@ export async function handleUpdateMemoryConversation( export async function handleDeleteMemoryConversation( deps: ServerProviderDeps, conversationId: string, - query: { agentId?: string }, + query: { agentId?: string; requestingUserId?: string }, ): Promise> { try { const resolved = resolveMemory(deps, query.agentId); @@ -566,7 +627,23 @@ export async function handleDeleteMemoryConversation( }; } - await resolved.memory.deleteConversation(conversationId); + const conversation = await resolved.memory.getConversation(conversationId); + if (!conversation) { + return { + success: false, + error: "Conversation not found", + httpStatus: 404, + }; + } + + const ownershipError = assertConversationOwner(conversation, query.requestingUserId); + if (ownershipError) { + return ownershipError; + } + + const mutationOptions = + query.requestingUserId !== undefined ? { expectedUserId: query.requestingUserId } : undefined; + await resolved.memory.deleteConversation(conversationId, mutationOptions); return { success: true, data: { deleted: true }, @@ -579,6 +656,9 @@ export async function handleDeleteMemoryConversation( httpStatus: 404, }; } + if (error instanceof ConversationOwnershipMismatchError) { + return buildForbiddenResponse(); + } return buildErrorResponse(error); } } diff --git a/packages/server-elysia/src/auth/middleware.ts b/packages/server-elysia/src/auth/middleware.ts index e94779049..04cfadeab 100644 --- a/packages/server-elysia/src/auth/middleware.ts +++ b/packages/server-elysia/src/auth/middleware.ts @@ -8,6 +8,16 @@ import { } from "@voltagent/server-core"; import type { Context } from "elysia"; +const requestAuthenticatedUsers = new WeakMap(); + +export function setRequestAuthenticatedUser(request: Request, user: unknown): void { + requestAuthenticatedUsers.set(request, user); +} + +export function getRequestAuthenticatedUser(request: Request): unknown { + return requestAuthenticatedUsers.get(request); +} + /** * Create authentication middleware for Elysia * This middleware handles both authentication and user context injection @@ -81,6 +91,7 @@ export function createAuthMiddleware(authProvider: AuthProvider) { // Store user in store for route handlers to access store.authenticatedUser = user; + setRequestAuthenticatedUser(request, user); // Also inject user context into request body for agent/workflow execution injectUserContext(request, user); @@ -165,6 +176,7 @@ export function createAuthNextMiddleware( // Store user in store for route handlers to access store.authenticatedUser = user; + setRequestAuthenticatedUser(request, user); // Also inject user context into request body for agent/workflow execution injectUserContext(request, user); diff --git a/packages/server-elysia/src/routes/memory.routes.spec.ts b/packages/server-elysia/src/routes/memory.routes.spec.ts new file mode 100644 index 000000000..7aff89041 --- /dev/null +++ b/packages/server-elysia/src/routes/memory.routes.spec.ts @@ -0,0 +1,65 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@voltagent/server-core", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + handleListMemoryConversations: vi.fn().mockResolvedValue({ + success: true, + data: { conversations: [], total: 0, limit: 0, offset: 0 }, + }), + }; +}); + +import { MEMORY_ROUTES, handleListMemoryConversations } from "@voltagent/server-core"; +import { setRequestAuthenticatedUser } from "../auth/middleware"; +import { registerMemoryRoutes } from "./memory.routes"; + +type RouteHandler = (context: Record) => Promise; + +function createRouteCollector() { + const handlers = new Map(); + const app: Record> = {}; + + for (const method of ["get", "post", "patch", "delete"] as const) { + app[method] = vi.fn((path: string, handler: RouteHandler) => { + handlers.set(`${method}:${path}`, handler); + return app; + }); + } + + return { app, handlers }; +} + +describe("Elysia memory routes", () => { + const deps = {} as any; + const logger = { trace: vi.fn(), warn: vi.fn() } as any; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("passes the request authenticated user to list-conversations", async () => { + const { app, handlers } = createRouteCollector(); + registerMemoryRoutes(app as any, deps, logger); + const handler = handlers.get(`get:${MEMORY_ROUTES.listConversations.path}`); + expect(handler).toBeDefined(); + + const request = new Request("http://localhost/api/memory/conversations"); + setRequestAuthenticatedUser(request, { id: "user-bob" }); + await handler?.({ + query: { agentId: "agent-1", userId: "user-alice" }, + request, + set: {}, + }); + + expect(handleListMemoryConversations).toHaveBeenCalledWith( + deps, + expect.objectContaining({ + agentId: "agent-1", + userId: "user-alice", + requestingUserId: "user-bob", + }), + ); + }); +}); diff --git a/packages/server-elysia/src/routes/memory.routes.ts b/packages/server-elysia/src/routes/memory.routes.ts index 17e92db06..7a103878c 100644 --- a/packages/server-elysia/src/routes/memory.routes.ts +++ b/packages/server-elysia/src/routes/memory.routes.ts @@ -16,6 +16,7 @@ import { handleUpdateMemoryWorkingMemory, } from "@voltagent/server-core"; import type { Elysia } from "elysia"; +import { getRequestAuthenticatedUser } from "../auth/middleware"; function parseNumber(value?: string | number): number | undefined { if (value === undefined || value === null) { @@ -41,6 +42,20 @@ function parseDate(value?: string): Date | undefined { return Number.isNaN(parsed.getTime()) ? undefined : parsed; } +function getAuthenticatedUserId(request: Request): string | undefined { + const user = getRequestAuthenticatedUser(request) as + | { id?: unknown; sub?: unknown } + | null + | undefined; + if (typeof user?.id === "string") { + return user.id; + } + if (typeof user?.sub === "string") { + return user.sub; + } + return undefined; +} + type MemoryRoutesCompat = typeof MEMORY_ROUTES & { getWorkingMemory?: { path: string }; }; @@ -54,12 +69,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, request, set }) => { 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, + requestingUserId: getAuthenticatedUserId(request), 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 +85,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, request, set }) => { 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, + requestingUserId: getAuthenticatedUserId(request), }); 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, request, set }) => { const conversationId = params.conversationId; logger.trace(`GET /api/memory/conversations/${conversationId}/messages - fetching messages`, { query, @@ -91,6 +108,7 @@ 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, + requestingUserId: getAuthenticatedUserId(request), }); set.status = response.success ? 200 : (response.httpStatus ?? 500); return response; @@ -135,23 +153,28 @@ export function registerMemoryRoutes(app: Elysia, deps: ServerProviderDeps, logg return response; }); - app.patch(MEMORY_ROUTES.updateConversation.path, async ({ params, body, query, set }) => { - 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), - }); - set.status = response.success ? 200 : (response.httpStatus ?? 500); - return response; - }); - - app.delete(MEMORY_ROUTES.deleteConversation.path, async ({ params, query, set }) => { + app.patch( + MEMORY_ROUTES.updateConversation.path, + async ({ params, body, query, request, set }) => { + 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), + requestingUserId: getAuthenticatedUserId(request), + }); + set.status = response.success ? 200 : (response.httpStatus ?? 500); + return response; + }, + ); + + app.delete(MEMORY_ROUTES.deleteConversation.path, async ({ params, query, request, set }) => { 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, + requestingUserId: getAuthenticatedUserId(request), }); set.status = response.success ? 200 : (response.httpStatus ?? 500); return response; diff --git a/packages/server-hono/src/routes/memory.routes.spec.ts b/packages/server-hono/src/routes/memory.routes.spec.ts new file mode 100644 index 000000000..08ae13146 --- /dev/null +++ b/packages/server-hono/src/routes/memory.routes.spec.ts @@ -0,0 +1,66 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@voltagent/server-core", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + handleListMemoryConversations: vi.fn().mockResolvedValue({ + success: true, + data: { conversations: [], total: 0, limit: 0, offset: 0 }, + }), + }; +}); + +import { MEMORY_ROUTES, handleListMemoryConversations } from "@voltagent/server-core"; +import { registerMemoryRoutes } from "./memory.routes"; + +type RouteHandler = (context: Record) => Promise; + +function createRouteCollector() { + const handlers = new Map(); + const app: Record> = {}; + + for (const method of ["get", "post", "patch", "delete"] as const) { + app[method] = vi.fn((path: string, handler: RouteHandler) => { + handlers.set(`${method}:${path}`, handler); + return app; + }); + } + + return { app, handlers }; +} + +describe("Hono memory routes", () => { + const deps = {} as any; + const logger = { trace: vi.fn(), warn: vi.fn() } as any; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("passes the authenticated user to list-conversations", async () => { + const { app, handlers } = createRouteCollector(); + registerMemoryRoutes(app as any, deps, logger); + const handler = handlers.get(`get:${MEMORY_ROUTES.listConversations.path}`); + expect(handler).toBeDefined(); + + const context = { + req: { + query: () => ({ agentId: "agent-1", userId: "user-alice" }), + }, + get: vi.fn((key: string) => (key === "authenticatedUser" ? { sub: "user-bob" } : undefined)), + json: vi.fn((response: unknown, status: number) => ({ response, status })), + }; + + await handler?.(context); + + expect(handleListMemoryConversations).toHaveBeenCalledWith( + deps, + expect.objectContaining({ + agentId: "agent-1", + userId: "user-alice", + requestingUserId: "user-bob", + }), + ); + }); +}); diff --git a/packages/server-hono/src/routes/memory.routes.ts b/packages/server-hono/src/routes/memory.routes.ts index 44e4ff58e..fe525f6eb 100644 --- a/packages/server-hono/src/routes/memory.routes.ts +++ b/packages/server-hono/src/routes/memory.routes.ts @@ -41,6 +41,17 @@ function parseDate(value?: string): Date | undefined { return Number.isNaN(parsed.getTime()) ? undefined : parsed; } +function getAuthenticatedUserId(c: { get?: (key: string) => unknown }): string | undefined { + const user = c.get?.("authenticatedUser") as { id?: unknown; sub?: unknown } | null | undefined; + if (typeof user?.id === "string") { + return user.id; + } + if (typeof user?.sub === "string") { + return user.sub; + } + return undefined; +} + const orderByAllowlist = new Set(["created_at", "updated_at", "title"]); function parseOrderBy(value?: string): "created_at" | "updated_at" | "title" | undefined { @@ -85,6 +96,7 @@ export function registerMemoryRoutes( agentId: query.agentId, resourceId: query.resourceId, userId: query.userId, + requestingUserId: getAuthenticatedUserId(c), limit: parseNumber(query.limit), offset: parseNumber(query.offset), orderBy: parseOrderBy(query.orderBy), @@ -100,6 +112,7 @@ export function registerMemoryRoutes( logger.trace(`GET /api/memory/conversations/${conversationId} - fetching conversation`); const response = await handleGetMemoryConversation(deps, conversationId, { agentId: query.agentId, + requestingUserId: getAuthenticatedUserId(c), }); return c.json(response, response.success ? 200 : (response.httpStatus ?? 500)); }); @@ -117,6 +130,7 @@ export function registerMemoryRoutes( after: parseDate(query.after), roles: query.roles ? query.roles.split(",") : undefined, userId: query.userId, + requestingUserId: getAuthenticatedUserId(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, + requestingUserId: getAuthenticatedUserId(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, + requestingUserId: getAuthenticatedUserId(c), }); return c.json(response, response.success ? 200 : (response.httpStatus ?? 500)); }); diff --git a/packages/supabase/package.json b/packages/supabase/package.json index bb9f3fb25..d544649de 100644 --- a/packages/supabase/package.json +++ b/packages/supabase/package.json @@ -32,7 +32,7 @@ "main": "dist/index.js", "module": "dist/index.mjs", "peerDependencies": { - "@voltagent/core": "^2.0.0", + "@voltagent/core": "^2.9.1", "@voltagent/logger": "^2.0.0", "ai": "^6.0.0" }, diff --git a/packages/supabase/src/memory-adapter.spec.ts b/packages/supabase/src/memory-adapter.spec.ts index fee5cc645..3c41ebbb8 100644 --- a/packages/supabase/src/memory-adapter.spec.ts +++ b/packages/supabase/src/memory-adapter.spec.ts @@ -229,6 +229,41 @@ describe.sequential("SupabaseMemoryAdapter - Core Functionality", () => { expect(mockClient.from).toHaveBeenCalledWith("voltagent_memory_conversations"); }); + it("adds expectedUserId to updateConversation mutations", async () => { + const existingRow = { + id: "conv-1", + resource_id: "resource-1", + user_id: "user-1", + title: "Old Title", + metadata: {}, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }; + const updatedRow = { ...existingRow, title: "Updated Title" }; + + supabaseMock.queue("voltagent_memory_conversations", ok(existingRow), ok(updatedRow)); + + await (adapter as any).updateConversation( + "conv-1", + { title: "Updated Title" }, + { expectedUserId: "user-1" }, + ); + + const updateBuilder = supabaseMock.getHistory("voltagent_memory_conversations")[1]; + expect(updateBuilder.eq).toHaveBeenCalledWith("id", "conv-1"); + expect(updateBuilder.eq).toHaveBeenCalledWith("user_id", "user-1"); + }); + + it("adds expectedUserId to deleteConversation mutations", async () => { + supabaseMock.queue("voltagent_memory_conversations", ok({ id: "conv-1" })); + + await (adapter as any).deleteConversation("conv-1", { expectedUserId: "user-1" }); + + const deleteBuilder = supabaseMock.getHistory("voltagent_memory_conversations")[0]; + expect(deleteBuilder.eq).toHaveBeenCalledWith("id", "conv-1"); + expect(deleteBuilder.eq).toHaveBeenCalledWith("user_id", "user-1"); + }); + it("should get conversations by resource ID", async () => { const dbRows = [ { diff --git a/packages/supabase/src/memory-adapter.ts b/packages/supabase/src/memory-adapter.ts index 7ce94542f..c59c10901 100644 --- a/packages/supabase/src/memory-adapter.ts +++ b/packages/supabase/src/memory-adapter.ts @@ -5,9 +5,14 @@ */ import { type SupabaseClient, createClient } from "@supabase/supabase-js"; -import { ConversationAlreadyExistsError, ConversationNotFoundError } from "@voltagent/core"; +import { + ConversationAlreadyExistsError, + ConversationNotFoundError, + ConversationOwnershipMismatchError, +} from "@voltagent/core"; import type { Conversation, + ConversationMutationOptions, ConversationQueryOptions, ConversationStepRecord, CreateConversationInput, @@ -1066,6 +1071,7 @@ END OF MIGRATION SQL async updateConversation( id: string, updates: Partial>, + options?: ConversationMutationOptions, ): Promise { await this.initialize(); @@ -1075,6 +1081,10 @@ END OF MIGRATION SQL throw new ConversationNotFoundError(id); } + if (options?.expectedUserId !== undefined && conversation.userId !== options.expectedUserId) { + throw new ConversationOwnershipMismatchError(id); + } + const now = new Date().toISOString(); const updateData: any = { updated_at: now }; @@ -1090,14 +1100,18 @@ END OF MIGRATION SQL updateData.metadata = updates.metadata; } - const { data, error } = await this.client - .from(conversationsTable) - .update(updateData) - .eq("id", id) - .select() - .single(); + let query = this.client.from(conversationsTable).update(updateData).eq("id", id); + + if (options?.expectedUserId !== undefined) { + query = query.eq("user_id", options.expectedUserId); + } + + const { data, error } = await query.select().single(); if (error) { + if (options?.expectedUserId !== undefined && error.code === "PGRST116") { + throw new ConversationOwnershipMismatchError(id); + } throw new Error(`Failed to update conversation: ${error.message}`); } @@ -1115,12 +1129,31 @@ END OF MIGRATION SQL /** * Delete a conversation */ - async deleteConversation(id: string): Promise { + async deleteConversation(id: string, options?: ConversationMutationOptions): Promise { await this.initialize(); const conversationsTable = `${this.baseTableName}_conversations`; - const { error } = await this.client.from(conversationsTable).delete().eq("id", id); + const deleteQuery = this.client.from(conversationsTable).delete().eq("id", id); + + if (options?.expectedUserId !== undefined) { + const { error } = await deleteQuery + .eq("user_id", options.expectedUserId) + .select("id") + .single(); + + if (error) { + if (error.code === "PGRST116") { + throw new ConversationOwnershipMismatchError(id); + } + throw new Error(`Failed to delete conversation: ${error.message}`); + } + + this.log(`Deleted conversation ${id}`); + return; + } + + const { error } = await deleteQuery; if (error) { throw new Error(`Failed to delete conversation: ${error.message}`); diff --git a/packages/voltagent-memory/package.json b/packages/voltagent-memory/package.json index 1a87d6e7a..c9ecf875e 100644 --- a/packages/voltagent-memory/package.json +++ b/packages/voltagent-memory/package.json @@ -29,7 +29,7 @@ "main": "dist/index.js", "module": "dist/index.mjs", "peerDependencies": { - "@voltagent/core": "^2.0.0", + "@voltagent/core": "^2.9.1", "ai": "^6.0.0" }, "repository": { diff --git a/packages/voltagent-memory/src/index.spec.ts b/packages/voltagent-memory/src/index.spec.ts new file mode 100644 index 000000000..3682ef9bf --- /dev/null +++ b/packages/voltagent-memory/src/index.spec.ts @@ -0,0 +1,153 @@ +import { ConversationOwnershipMismatchError } from "@voltagent/core"; +import { describe, expect, it, vi } from "vitest"; +import { ManagedMemoryAdapter } from "./index"; + +function createConversation(userId: string) { + return { + id: "conv-1", + userId, + resourceId: "agent-1", + title: "Private", + metadata: {}, + createdAt: "2024-01-01T00:00:00.000Z", + updatedAt: "2024-01-01T00:00:00.000Z", + }; +} + +function createVoltOpsClient() { + const conversations = { + create: vi.fn(), + get: vi.fn(), + query: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + }; + + return { + client: { + hasValidKeys: vi.fn(() => true), + listManagedMemoryDatabases: vi.fn().mockResolvedValue([ + { + id: "db-1", + name: "primary", + region: "test", + connection: {}, + }, + ]), + managedMemory: { + conversations, + messages: { + add: vi.fn(), + addBatch: vi.fn(), + list: vi.fn(), + clear: vi.fn(), + delete: vi.fn(), + }, + workingMemory: { + get: vi.fn(), + set: vi.fn(), + delete: vi.fn(), + }, + workflowStates: { + get: vi.fn(), + set: vi.fn(), + update: vi.fn(), + list: vi.fn(), + query: vi.fn(), + listSuspended: vi.fn(), + }, + steps: { + save: vi.fn(), + list: vi.fn(), + }, + vectors: { + store: vi.fn(), + storeBatch: vi.fn(), + search: vi.fn(), + get: vi.fn(), + delete: vi.fn(), + deleteBatch: vi.fn(), + clear: vi.fn(), + count: vi.fn(), + }, + }, + } as any, + conversations, + }; +} + +describe("ManagedMemoryAdapter conversation ownership guards", () => { + it("rejects guarded updates before delegating when the owner does not match", async () => { + const { client, conversations } = createVoltOpsClient(); + conversations.get.mockResolvedValue(createConversation("user-2")); + + const adapter = new ManagedMemoryAdapter({ databaseId: "db-1", voltOpsClient: client }); + + await expect( + adapter.updateConversation("conv-1", { title: "Updated" }, { expectedUserId: "user-1" }), + ).rejects.toBeInstanceOf(ConversationOwnershipMismatchError); + + expect(conversations.update).not.toHaveBeenCalled(); + }); + + it("delegates guarded updates when the owner matches", async () => { + const { client, conversations } = createVoltOpsClient(); + conversations.get.mockResolvedValue(createConversation("user-1")); + conversations.update.mockResolvedValue({ ...createConversation("user-1"), title: "Updated" }); + + const adapter = new ManagedMemoryAdapter({ databaseId: "db-1", voltOpsClient: client }); + + await expect( + adapter.updateConversation("conv-1", { title: "Updated" }, { expectedUserId: "user-1" }), + ).resolves.toMatchObject({ title: "Updated" }); + + expect(conversations.update).toHaveBeenCalledWith("db-1", { + conversationId: "conv-1", + updates: { title: "Updated" }, + expectedUserId: "user-1", + }); + }); + + it("checks ownership before delegated deletes", async () => { + const { client, conversations } = createVoltOpsClient(); + conversations.get.mockResolvedValue(createConversation("user-1")); + conversations.delete.mockResolvedValue(undefined); + + const adapter = new ManagedMemoryAdapter({ databaseId: "db-1", voltOpsClient: client }); + + await expect( + adapter.deleteConversation("conv-1", { expectedUserId: "user-1" }), + ).resolves.toBeUndefined(); + + expect(conversations.get).toHaveBeenCalledWith("db-1", "conv-1"); + expect(conversations.delete).toHaveBeenCalledWith("db-1", "conv-1", { + expectedUserId: "user-1", + }); + }); + + it("rejects guarded deletes before delegating when the owner does not match", async () => { + const { client, conversations } = createVoltOpsClient(); + conversations.get.mockResolvedValue(createConversation("user-2")); + + const adapter = new ManagedMemoryAdapter({ databaseId: "db-1", voltOpsClient: client }); + + await expect( + adapter.deleteConversation("conv-1", { expectedUserId: "user-1" }), + ).rejects.toBeInstanceOf(ConversationOwnershipMismatchError); + + expect(conversations.delete).not.toHaveBeenCalled(); + }); + + it("treats an explicitly empty expected owner as unauthorized", async () => { + const { client, conversations } = createVoltOpsClient(); + conversations.get.mockResolvedValue(createConversation("")); + + const adapter = new ManagedMemoryAdapter({ databaseId: "db-1", voltOpsClient: client }); + + await expect( + adapter.updateConversation("conv-1", { title: "Updated" }, { expectedUserId: "" }), + ).rejects.toBeInstanceOf(ConversationOwnershipMismatchError); + + expect(conversations.update).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/voltagent-memory/src/index.ts b/packages/voltagent-memory/src/index.ts index 75981d47b..7c9bf8dc4 100644 --- a/packages/voltagent-memory/src/index.ts +++ b/packages/voltagent-memory/src/index.ts @@ -1,6 +1,9 @@ import { AgentRegistry, type Conversation, + type ConversationMutationOptions, + ConversationNotFoundError, + ConversationOwnershipMismatchError, type ConversationQueryOptions, type ConversationStepRecord, type CreateConversationInput, @@ -165,6 +168,28 @@ export class ManagedMemoryAdapter implements StorageAdapter { return handler({ client: this.voltOpsClient, database: this.database }); } + private async assertExpectedConversationOwner( + id: string, + options?: ConversationMutationOptions, + ): Promise { + if (options?.expectedUserId === undefined) { + return; + } + + if (options.expectedUserId === "") { + throw new ConversationOwnershipMismatchError(id); + } + + const conversation = await this.getConversation(id); + if (!conversation) { + throw new ConversationNotFoundError(id); + } + + if (conversation.userId !== options.expectedUserId) { + throw new ConversationOwnershipMismatchError(id); + } + } + private log(message: string, context?: string): void { if (this.debug) { console.log("[ManagedMemoryAdapter]", message, context ?? ""); @@ -351,23 +376,37 @@ export class ManagedMemoryAdapter implements StorageAdapter { }); } - updateConversation( + async updateConversation( id: string, updates: Partial>, + options?: ConversationMutationOptions, ): Promise { + await this.assertExpectedConversationOwner(id, options); + return this.withClientContext(({ client, database }) => { this.log("Updating managed memory conversation", safeStringify({ id, updates })); return client.managedMemory.conversations.update(database.id, { conversationId: id, updates, + ...(options?.expectedUserId !== undefined + ? { expectedUserId: options.expectedUserId } + : {}), }); }); } - deleteConversation(id: string): Promise { + async deleteConversation(id: string, options?: ConversationMutationOptions): Promise { + await this.assertExpectedConversationOwner(id, options); + return this.withClientContext(async ({ client, database }) => { this.log("Deleting managed memory conversation", safeStringify({ id })); - await client.managedMemory.conversations.delete(database.id, id); + await client.managedMemory.conversations.delete( + database.id, + id, + options?.expectedUserId !== undefined + ? { expectedUserId: options.expectedUserId } + : undefined, + ); }).then(() => undefined); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f8865a8b7..a14708b57 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4374,7 +4374,7 @@ importers: specifier: ^1.12.1 version: 1.17.2 '@voltagent/core': - specifier: ^2.8.1 + specifier: ^2.9.1 version: link:../core '@voltagent/internal': specifier: ^1.0.2