ソースを参照

security/perf: fix critical and high issues from code review

Critical:
- Remove password reset token from API response (account takeover risk)
- Validate returnTo parameter to prevent open redirect attacks

High:
- Throw on empty/short JWT_SECRET at runtime instead of signing with ""
- Remove manus-runtime-user-info localStorage leak from useAuth
- Add LIMIT 1000 to searchKnowledge to prevent full table scan
- Replace N-query loops with single inArray() in bulk update/delete

Medium:
- Cookie path now configurable via COOKIE_BASE_PATH env var
- bulkUpdateConversationStatus and deleteConversations use single SQL

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Tony T 1 週間 前
コミット
858bb28856

BIN
Homelegance_AI_Chatbot_Platform_—_Board_Review_Summary.pdf


+ 0 - 4
client/src/_core/hooks/useAuth.ts

@@ -42,10 +42,6 @@ export function useAuth(options?: UseAuthOptions) {
   }, [logoutMutation, utils]);
 
   const state = useMemo(() => {
-    localStorage.setItem(
-      "manus-runtime-user-info",
-      JSON.stringify(meQuery.data)
-    );
     return {
       user: meQuery.data ?? null,
       loading: meQuery.isLoading || logoutMutation.isPending,

+ 5 - 1
client/src/pages/Login.tsx

@@ -38,7 +38,11 @@ export default function Login() {
       // Redirect to dashboard after login
       const params = new URLSearchParams(window.location.search);
       const base = import.meta.env.BASE_URL ?? "/";
-      const returnTo = params.get("returnTo") || `${base}dashboard`;
+      const raw = params.get("returnTo");
+      // Validate returnTo to prevent open redirect attacks (must be a relative path)
+      const returnTo = (raw && raw.startsWith("/") && !raw.startsWith("//"))
+        ? raw
+        : `${base}dashboard`;
       window.location.href = returnTo;
     },
     onError: (err) => {

+ 446 - 0
deploy/create-report.mjs

@@ -0,0 +1,446 @@
+import {
+  Document, Packer, Paragraph, TextRun, Table, TableRow, TableCell,
+  AlignmentType, HeadingLevel, BorderStyle, WidthType, ShadingType,
+  VerticalAlign, LevelFormat, ExternalHyperlink, Header, Footer, PageNumber
+} from "docx";
+import { writeFileSync } from "fs";
+
+// ── Colors ───────────────────────────────────────────────────────────────────
+const CLAUDE_ORANGE = "E8834B";
+const HEADER_BG     = "2C3E50";
+const ROW_ALT       = "F8F9FA";
+const ROW_HIGHLIGHT = "FFF3CD";
+const BORDER_COLOR  = "CCCCCC";
+const WHITE         = "FFFFFF";
+
+const border = (color = BORDER_COLOR) => ({ style: BorderStyle.SINGLE, size: 1, color });
+const allBorders = (color = BORDER_COLOR) => ({
+  top: border(color), bottom: border(color),
+  left: border(color), right: border(color),
+});
+
+// ── Helper: table cell ────────────────────────────────────────────────────────
+function cell(text, { bold = false, bg = WHITE, width, align = AlignmentType.LEFT, color = "000000", fontSize = 20 } = {}) {
+  return new TableCell({
+    borders: allBorders(),
+    width: { size: width, type: WidthType.DXA },
+    shading: { fill: bg, type: ShadingType.CLEAR },
+    verticalAlign: VerticalAlign.CENTER,
+    margins: { top: 80, bottom: 80, left: 120, right: 120 },
+    children: [new Paragraph({
+      alignment: align,
+      children: [new TextRun({ text, bold, color, size: fontSize, font: "Arial" })],
+    })],
+  });
+}
+
+// ── Helper: header cell (dark bg, white text) ─────────────────────────────────
+function headerCell(text, width) {
+  return cell(text, { bold: true, bg: HEADER_BG, color: WHITE, width, fontSize: 20 });
+}
+
+// ── Helper: section heading ───────────────────────────────────────────────────
+function sectionHeading(text) {
+  return new Paragraph({
+    spacing: { before: 320, after: 160 },
+    children: [new TextRun({ text, bold: true, size: 28, color: "2C3E50", font: "Arial" })],
+    border: { bottom: { style: BorderStyle.SINGLE, size: 4, color: CLAUDE_ORANGE, space: 4 } },
+  });
+}
+
+// ── Helper: normal paragraph ─────────────────────────────────────────────────
+function para(text, { bold = false, size = 20, spacing = { after: 120 } } = {}) {
+  return new Paragraph({
+    spacing,
+    children: [new TextRun({ text, bold, size, font: "Arial", color: "333333" })],
+  });
+}
+
+// ── Helper: bullet ────────────────────────────────────────────────────────────
+function bullet(text) {
+  return new Paragraph({
+    numbering: { reference: "bullets", level: 0 },
+    spacing: { after: 80 },
+    children: [new TextRun({ text, size: 20, font: "Arial", color: "333333" })],
+  });
+}
+
+// ── Helper: note paragraph ───────────────────────────────────────────────────
+function note(text) {
+  return new Paragraph({
+    spacing: { before: 100, after: 160 },
+    children: [new TextRun({ text: `📌 ${text}`, size: 18, italics: true, color: "555555", font: "Arial" })],
+  });
+}
+
+// ── Section 1: Core pricing table (6 columns) ─────────────────────────────────
+// Content width: A4 9026, use 9000 for slight breathing room
+const COL1 = 1500; // 层级
+const COL2 = 2000; // Claude model
+const COL3 = 1500; // Claude price
+const COL4 = 2000; // OpenAI model
+const COL5 = 1500; // OpenAI price
+const TABLE1_WIDTH = COL1 + COL2 + COL3 + COL4 + COL5; // 8500
+
+function pricingTable() {
+  const rows = [
+    ["旗舰",       "Claude Opus 4.6",   "$5 / $25",  "GPT-4o",      "$2.50 / $10",  false],
+    ["标准(推荐)", "Claude Sonnet 4.6", "$3 / $15",  "GPT-4o",      "$2.50 / $10",  true ],
+    ["轻量",        "Claude Haiku 4.5",  "$1 / $5",   "GPT-4o mini", "$0.15 / $0.60", false],
+  ];
+
+  return new Table({
+    width: { size: TABLE1_WIDTH, type: WidthType.DXA },
+    columnWidths: [COL1, COL2, COL3, COL4, COL5],
+    rows: [
+      new TableRow({
+        tableHeader: true,
+        children: [
+          headerCell("层级", COL1),
+          headerCell("Claude(Anthropic)", COL2),
+          headerCell("价格(输入/输出)", COL3),
+          headerCell("ChatGPT(OpenAI)", COL4),
+          headerCell("价格(输入/输出)", COL5),
+        ],
+      }),
+      ...rows.map(([tier, claude, cp, oai, op, highlight]) =>
+        new TableRow({
+          children: [
+            cell(tier,   { width: COL1, bg: highlight ? ROW_HIGHLIGHT : WHITE, bold: highlight }),
+            cell(claude, { width: COL2, bg: highlight ? ROW_HIGHLIGHT : WHITE, bold: highlight, color: highlight ? "C0392B" : "000000" }),
+            cell(cp,     { width: COL3, bg: highlight ? ROW_HIGHLIGHT : WHITE, bold: highlight, align: AlignmentType.CENTER }),
+            cell(oai,    { width: COL4, bg: highlight ? ROW_HIGHLIGHT : WHITE }),
+            cell(op,     { width: COL5, bg: highlight ? ROW_HIGHLIGHT : WHITE, align: AlignmentType.CENTER }),
+          ],
+        })
+      ),
+    ],
+  });
+}
+
+// ── Section 2: Cost estimate table ───────────────────────────────────────────
+const E_COL1 = 2200;
+const E_COL2 = 3200;
+const E_COL3 = 3200;
+const TABLE2_WIDTH = E_COL1 + E_COL2 + E_COL3; // 8600
+
+function costTable() {
+  const rows = [
+    ["每次对话成本",      "~$0.0037",  "~$0.0027"],
+    ["1,000 次对话/月",   "$3.70",     "$2.70"],
+    ["10,000 次对话/月",  "$37",       "$27"],
+    ["100,000 次对话/月", "$370",      "$270"],
+  ];
+  return new Table({
+    width: { size: TABLE2_WIDTH, type: WidthType.DXA },
+    columnWidths: [E_COL1, E_COL2, E_COL3],
+    rows: [
+      new TableRow({
+        tableHeader: true,
+        children: [
+          headerCell("", E_COL1),
+          headerCell("Claude Sonnet 4.6", E_COL2),
+          headerCell("GPT-4o", E_COL3),
+        ],
+      }),
+      ...rows.map(([label, claude, oai], i) =>
+        new TableRow({
+          children: [
+            cell(label, { width: E_COL1, bg: i % 2 === 0 ? WHITE : ROW_ALT, bold: true }),
+            cell(claude, { width: E_COL2, bg: i % 2 === 0 ? WHITE : ROW_ALT, align: AlignmentType.CENTER, color: "C0392B", bold: true }),
+            cell(oai,    { width: E_COL3, bg: i % 2 === 0 ? WHITE : ROW_ALT, align: AlignmentType.CENTER }),
+          ],
+        })
+      ),
+    ],
+  });
+}
+
+// ── Section 3: Feature comparison ────────────────────────────────────────────
+const F_COL1 = 2400;
+const F_COL2 = 3000;
+const F_COL3 = 3000;
+const TABLE3_WIDTH = F_COL1 + F_COL2 + F_COL3; // 8400
+
+function featureTable(rows) {
+  return new Table({
+    width: { size: TABLE3_WIDTH, type: WidthType.DXA },
+    columnWidths: [F_COL1, F_COL2, F_COL3],
+    rows: [
+      new TableRow({
+        tableHeader: true,
+        children: [
+          headerCell("功能", F_COL1),
+          headerCell("Claude API", F_COL2),
+          headerCell("ChatGPT API", F_COL3),
+        ],
+      }),
+      ...rows.map(([feat, claude, oai], i) =>
+        new TableRow({
+          children: [
+            cell(feat,   { width: F_COL1, bg: i % 2 === 0 ? WHITE : ROW_ALT, bold: true }),
+            cell(claude, { width: F_COL2, bg: i % 2 === 0 ? WHITE : ROW_ALT, color: "1A6B2A" }),
+            cell(oai,    { width: F_COL3, bg: i % 2 === 0 ? WHITE : ROW_ALT }),
+          ],
+        })
+      ),
+    ],
+  });
+}
+
+// ── Section 4: Capability comparison ─────────────────────────────────────────
+const C_COL1 = 2200;
+const C_COL2 = 3200;
+const C_COL3 = 3200;
+const TABLE4_WIDTH = C_COL1 + C_COL2 + C_COL3;
+
+function capabilityTable(rows) {
+  return new Table({
+    width: { size: TABLE4_WIDTH, type: WidthType.DXA },
+    columnWidths: [C_COL1, C_COL2, C_COL3],
+    rows: [
+      new TableRow({
+        tableHeader: true,
+        children: [
+          headerCell("维度", C_COL1),
+          headerCell("Claude Sonnet 4.6", C_COL2),
+          headerCell("GPT-4o", C_COL3),
+        ],
+      }),
+      ...rows.map(([dim, claude, oai], i) =>
+        new TableRow({
+          children: [
+            cell(dim,   { width: C_COL1, bg: i % 2 === 0 ? WHITE : ROW_ALT, bold: true }),
+            cell(claude, { width: C_COL2, bg: i % 2 === 0 ? WHITE : ROW_ALT }),
+            cell(oai,    { width: C_COL3, bg: i % 2 === 0 ? WHITE : ROW_ALT }),
+          ],
+        })
+      ),
+    ],
+  });
+}
+
+// ── Section 6: Annual forecast ────────────────────────────────────────────────
+const Y_COL1 = 2200;
+const Y_COL2 = 3200;
+const Y_COL3 = 3200;
+const TABLE5_WIDTH = Y_COL1 + Y_COL2 + Y_COL3;
+
+function yearlyTable() {
+  const rows = [
+    ["月费估算", "~$30 – $50", "~$25 – $40"],
+    ["年费估算", "~$360 – $600", "~$300 – $480"],
+  ];
+  return new Table({
+    width: { size: TABLE5_WIDTH, type: WidthType.DXA },
+    columnWidths: [Y_COL1, Y_COL2, Y_COL3],
+    rows: [
+      new TableRow({
+        tableHeader: true,
+        children: [
+          headerCell("", Y_COL1),
+          headerCell("Claude Sonnet 4.6(含缓存)", Y_COL2),
+          headerCell("GPT-4o(含缓存)", Y_COL3),
+        ],
+      }),
+      ...rows.map(([label, claude, oai], i) =>
+        new TableRow({
+          children: [
+            cell(label,  { width: Y_COL1, bg: i % 2 === 0 ? WHITE : ROW_ALT, bold: true }),
+            cell(claude, { width: Y_COL2, bg: i % 2 === 0 ? WHITE : ROW_ALT, align: AlignmentType.CENTER, bold: true, color: "C0392B" }),
+            cell(oai,    { width: Y_COL3, bg: i % 2 === 0 ? WHITE : ROW_ALT, align: AlignmentType.CENTER }),
+          ],
+        })
+      ),
+    ],
+  });
+}
+
+// ── Build document ────────────────────────────────────────────────────────────
+const doc = new Document({
+  numbering: {
+    config: [
+      {
+        reference: "bullets",
+        levels: [{
+          level: 0, format: LevelFormat.BULLET, text: "\u2022",
+          alignment: AlignmentType.LEFT,
+          style: { paragraph: { indent: { left: 720, hanging: 360 } } },
+        }],
+      },
+    ],
+  },
+  styles: {
+    default: {
+      document: { run: { font: "Arial", size: 20, color: "333333" } },
+    },
+  },
+  sections: [{
+    properties: {
+      page: {
+        size: { width: 11906, height: 16838 }, // A4
+        margin: { top: 1080, right: 1080, bottom: 1080, left: 1080 },
+      },
+    },
+    headers: {
+      default: new Header({
+        children: [new Paragraph({
+          alignment: AlignmentType.RIGHT,
+          border: { bottom: { style: BorderStyle.SINGLE, size: 4, color: CLAUDE_ORANGE, space: 4 } },
+          spacing: { after: 200 },
+          children: [new TextRun({
+            text: "Claude API vs ChatGPT API — 价格对比报告",
+            size: 16, color: "888888", font: "Arial",
+          })],
+        })],
+      }),
+    },
+    footers: {
+      default: new Footer({
+        children: [new Paragraph({
+          alignment: AlignmentType.CENTER,
+          border: { top: { style: BorderStyle.SINGLE, size: 2, color: BORDER_COLOR, space: 4 } },
+          spacing: { before: 100 },
+          children: [
+            new TextRun({ text: "数据来源:platform.claude.com / openai.com/api/pricing  |  第 ", size: 16, color: "888888", font: "Arial" }),
+            new TextRun({ children: [PageNumber.CURRENT], size: 16, color: "888888", font: "Arial" }),
+            new TextRun({ text: " 页", size: 16, color: "888888", font: "Arial" }),
+          ],
+        })],
+      }),
+    },
+    children: [
+      // ── Title block ──────────────────────────────────────────────────────
+      new Paragraph({
+        spacing: { before: 0, after: 80 },
+        children: [new TextRun({
+          text: "Claude API  vs  ChatGPT API",
+          bold: true, size: 52, color: "2C3E50", font: "Arial",
+        })],
+      }),
+      new Paragraph({
+        spacing: { before: 0, after: 60 },
+        children: [new TextRun({
+          text: "价格对比报告",
+          bold: true, size: 36, color: CLAUDE_ORANGE, font: "Arial",
+        })],
+      }),
+      new Paragraph({
+        spacing: { after: 320 },
+        border: { bottom: { style: BorderStyle.SINGLE, size: 6, color: CLAUDE_ORANGE, space: 6 } },
+        children: [new TextRun({ text: "制作日期:2026年4月", size: 18, color: "888888", font: "Arial" })],
+      }),
+
+      // ── Section 1 ────────────────────────────────────────────────────────
+      sectionHeading("一、核心模型价格对比(每百万 Token)"),
+      pricingTable(),
+      note("本项目 chatbot 使用 Claude Sonnet 4.6,对标 OpenAI 的 GPT-4o 层级。"),
+
+      // ── Section 2 ────────────────────────────────────────────────────────
+      sectionHeading("二、实际对话成本估算"),
+      para("以 chatbot 典型对话为例:"),
+      bullet("输入:~500 tokens(系统提示 + 对话历史)"),
+      bullet("输出:~150 tokens(Bot 回复)"),
+      new Paragraph({ spacing: { after: 120 } }),
+      costTable(),
+      note("月差额约 $10–$100,对企业级系统而言可忽略不计。"),
+
+      // ── Section 3 ────────────────────────────────────────────────────────
+      sectionHeading("三、成本优化功能对比"),
+      featureTable([
+        ["Batch 批处理折扣",  "标准价 5折",              "标准价 5折"],
+        ["Prompt Caching",    "命中缓存仅需原价 10%",    "命中缓存 50%"],
+        ["Context 窗口",      "200K tokens",            "128K tokens"],
+        ["最长单次输入",      "更适合长文档/历史记录",   "受限于 128K"],
+      ]),
+      note("Prompt Caching:本项目系统提示词(Ellie 角色设定)每次对话都会重复发送,Claude 缓存命中价格仅 $0.30/百万 tokens(原价 $3 的 10%),长期运行下成本优势显著。"),
+
+      // ── Section 4 ────────────────────────────────────────────────────────
+      sectionHeading("四、功能与定位对比"),
+      capabilityTable([
+        ["中文理解质量",  "优秀",                              "优秀"],
+        ["长上下文处理",  "200K tokens(领先)",               "128K tokens"],
+        ["代码生成",      "优秀",                              "优秀"],
+        ["内容安全合规",  "Constitutional AI,策略更严谨",      "中等"],
+        ["响应稳定性",    "更一致",                            "中等"],
+        ["企业 SLA",      "有",                                "有"],
+        ["数据隐私",      "不用于模型训练",                    "不用于模型训练"],
+        ["API 成熟度",    "成熟",                              "成熟"],
+      ]),
+
+      // ── Section 5 ────────────────────────────────────────────────────────
+      sectionHeading("五、推荐选择 Claude API 的理由"),
+
+      para("1.  成本差异极小,价值更高", { bold: true }),
+      para("对于本项目规模(B2B dealer chatbot),两者月费差额通常在 $10–$50 以内,而 Claude 在长文档处理和一致性上表现更好。"),
+
+      para("2.  Prompt Caching 优势明显", { bold: true }),
+      para("本项目系统提示词固定(Ellie 角色设定 + ERP 数据上下文),每次对话都能命中缓存,实际输入成本可降至标准价的 10%,有效抵消价格差。"),
+
+      para("3.  200K 上下文窗口", { bold: true }),
+      para("随着 ERP 数据集成(订单详情、产品目录),注入 LLM 的 context 数据量会增加。200K 窗口提供更大余量,无需担心超出限制。"),
+
+      para("4.  技术栈已完成集成", { bold: true }),
+      para("项目代码已使用 claude-sonnet-4-6 完成集成,切换到 ChatGPT API 需要额外改造工作和测试成本。"),
+
+      para("5.  内容安全合规", { bold: true }),
+      para("Anthropic 的 Constitutional AI 在拒绝不适当请求、保持回复边界方面更可靠,适合面向 dealer 的 B2B 场景。"),
+
+      // ── Section 6 ────────────────────────────────────────────────────────
+      sectionHeading("六、费用预测(年度)"),
+      para("假设每月 20,000 次对话,启用 Prompt Caching:"),
+      yearlyTable(),
+      note("两者年费差额约 $60–$120,可忽略不计。"),
+
+      // ── Conclusion ───────────────────────────────────────────────────────
+      new Paragraph({
+        spacing: { before: 320, after: 160 },
+        shading: { fill: "EBF5FB", type: ShadingType.CLEAR },
+        border: {
+          top:    { style: BorderStyle.SINGLE, size: 4, color: CLAUDE_ORANGE },
+          bottom: { style: BorderStyle.SINGLE, size: 4, color: CLAUDE_ORANGE },
+          left:   { style: BorderStyle.THICK,  size: 12, color: CLAUDE_ORANGE },
+          right:  { style: BorderStyle.SINGLE, size: 4, color: CLAUDE_ORANGE },
+        },
+        children: [new TextRun({
+          text: "结论:推荐使用 Claude API(Anthropic)",
+          bold: true, size: 26, color: "2C3E50", font: "Arial",
+        })],
+      }),
+      para("在价格接近的前提下,Claude 提供更大的 Context 窗口、更优的 Prompt Caching 折扣,且代码已完成集成,综合性价比更高,是本项目的最优选择。"),
+
+      // ── Sources ──────────────────────────────────────────────────────────
+      new Paragraph({
+        spacing: { before: 400, after: 80 },
+        border: { top: { style: BorderStyle.SINGLE, size: 2, color: BORDER_COLOR, space: 6 } },
+        children: [new TextRun({ text: "数据来源", bold: true, size: 18, color: "888888", font: "Arial" })],
+      }),
+      new Paragraph({
+        spacing: { after: 60 },
+        children: [
+          new TextRun({ text: "Anthropic Claude API Pricing: ", size: 16, color: "888888", font: "Arial" }),
+          new ExternalHyperlink({
+            link: "https://platform.claude.com/docs/en/about-claude/pricing",
+            children: [new TextRun({ text: "platform.claude.com/docs/en/about-claude/pricing", size: 16, style: "Hyperlink", font: "Arial" })],
+          }),
+        ],
+      }),
+      new Paragraph({
+        spacing: { after: 60 },
+        children: [
+          new TextRun({ text: "OpenAI API Pricing: ", size: 16, color: "888888", font: "Arial" }),
+          new ExternalHyperlink({
+            link: "https://openai.com/api/pricing/",
+            children: [new TextRun({ text: "openai.com/api/pricing", size: 16, style: "Hyperlink", font: "Arial" })],
+          }),
+        ],
+      }),
+    ],
+  }],
+});
+
+const outPath = "C:\\Codes\\homelegance-chatbot\\deploy\\Claude_vs_ChatGPT_API价格对比.docx";
+Packer.toBuffer(doc).then(buf => {
+  writeFileSync(outPath, buf);
+  console.log("Created:", outPath);
+});

+ 5 - 3
deploy/部署文档.md

@@ -64,11 +64,13 @@ pnpm --version
 pm2 --version
 ```
 
-### 2.3 安装 Python(用于 ERP 桥接服务)
+### 2.3 安装 Python 依赖(用于 ERP 桥接服务)
+
+系统已有 Python 3.12,直接安装所需包:
 
 ```bash
-sudo dnf install -y python3.11 python3.11-pip
-pip3.11 install fastapi uvicorn asyncpg python-dotenv
+python3.12 -V   # 确认 Python 3.12.x
+pip3.12 install fastapi uvicorn asyncpg python-dotenv
 ```
 
 ---

+ 4 - 1
server/_core/cookies.ts

@@ -40,9 +40,12 @@ export function getSessionCookieOptions(
   //       : undefined;
 
   const secure = isSecureRequest(req);
+  // Scope the cookie to /chat/ so it is not sent to unrelated paths on the same domain.
+  // Must match the Apache Alias base path for the chatbot.
+  const basePath = process.env.COOKIE_BASE_PATH ?? "/";
   return {
     httpOnly: true,
-    path: "/",
+    path: basePath,
     // SameSite=Lax is correct for same-site deployments (www.homelegance.com/chat/).
     // SameSite=None would require Secure=true which fails behind Apache proxy without trust-proxy config.
     sameSite: "lax",

+ 3 - 0
server/_core/sdk.ts

@@ -156,6 +156,9 @@ class SDKServer {
 
   private getSessionSecret() {
     const secret = ENV.cookieSecret;
+    if (!secret || secret.length < 32) {
+      throw new Error("[Auth] JWT_SECRET is not set or too short (minimum 32 characters). Set JWT_SECRET in .env.production.");
+    }
     return new TextEncoder().encode(secret);
   }
 

+ 9 - 9
server/db.ts

@@ -1,4 +1,4 @@
-import { eq, desc, asc, and, sql, like, or, lt, gte, lte, isNotNull } from "drizzle-orm";
+import { eq, desc, asc, and, sql, like, or, lt, gte, lte, isNotNull, inArray } from "drizzle-orm";
 import { drizzle } from "drizzle-orm/postgres-js";
 import {
   InsertUser, users,
@@ -414,11 +414,10 @@ export async function bulkUpdateConversationStatus(
 ) {
   const db = await getDb();
   if (!db) throw new Error("Database not available");
+  if (ids.length === 0) return { updated: 0 };
   const updateData: Record<string, unknown> = { status };
   if (agentId !== undefined) updateData.assignedAgentId = agentId;
-  for (const id of ids) {
-    await db.update(conversations).set(updateData).where(eq(conversations.id, id));
-  }
+  await db.update(conversations).set(updateData).where(inArray(conversations.id, ids));
   return { updated: ids.length };
 }
 
@@ -426,10 +425,10 @@ export async function bulkUpdateConversationStatus(
 export async function deleteConversations(ids: number[]) {
   const db = await getDb();
   if (!db) throw new Error("Database not available");
-  for (const id of ids) {
-    await db.delete(messages).where(eq(messages.conversationId, id));
-    await db.delete(conversations).where(eq(conversations.id, id));
-  }
+  if (ids.length === 0) return { deleted: 0 };
+  // Delete messages first, then conversations — both in a single statement each
+  await db.delete(messages).where(inArray(messages.conversationId, ids));
+  await db.delete(conversations).where(inArray(conversations.id, ids));
   return { deleted: ids.length };
 }
 
@@ -717,7 +716,8 @@ export async function searchKnowledge(userQuestion: string): Promise<{ id: numbe
   const entries = await db
     .select({ id: knowledgeEntries.id, question: knowledgeEntries.question, answer: knowledgeEntries.answer, category: knowledgeEntries.category })
     .from(knowledgeEntries)
-    .where(eq(knowledgeEntries.status, "active"));
+    .where(eq(knowledgeEntries.status, "active"))
+    .limit(1000); // safety cap — migrate to DB full-text search when KB exceeds this
 
   if (!entries.length) return null;
 

+ 1 - 1
server/routers.ts

@@ -179,7 +179,7 @@ export const appRouter = router({
           });
         } catch (e) { /* non-critical */ }
 
-        return { success: true, message: "If an account with that email exists, a reset link has been generated.", resetToken: token };
+        return { success: true, message: "If an account with that email exists, a reset link has been generated." };
       }),
 
     /** Validate a password reset token */