cli-claw-kit 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (295) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +245 -0
  3. package/config/default-groups.json +1 -0
  4. package/config/global-agents-md.template.md +37 -0
  5. package/config/mount-allowlist.json +11 -0
  6. package/container/Dockerfile +160 -0
  7. package/container/agent-runner/dist/.tsbuildinfo +1 -0
  8. package/container/agent-runner/dist/agent-definitions.js +22 -0
  9. package/container/agent-runner/dist/channel-prefixes.js +16 -0
  10. package/container/agent-runner/dist/codex-config.js +29 -0
  11. package/container/agent-runner/dist/image-detector.js +96 -0
  12. package/container/agent-runner/dist/index.js +2587 -0
  13. package/container/agent-runner/dist/mcp-tools.js +1076 -0
  14. package/container/agent-runner/dist/stream-event.types.js +5 -0
  15. package/container/agent-runner/dist/stream-processor.js +867 -0
  16. package/container/agent-runner/dist/types.js +6 -0
  17. package/container/agent-runner/dist/utils.js +115 -0
  18. package/container/agent-runner/package.json +36 -0
  19. package/container/agent-runner/prompts/security-rules.md +31 -0
  20. package/container/agent-runner/src/agent-definitions.ts +27 -0
  21. package/container/agent-runner/src/channel-prefixes.ts +16 -0
  22. package/container/agent-runner/src/codex-config.ts +40 -0
  23. package/container/agent-runner/src/image-detector.ts +116 -0
  24. package/container/agent-runner/src/index.ts +3107 -0
  25. package/container/agent-runner/src/mcp-tools.ts +1295 -0
  26. package/container/agent-runner/src/stream-event.types.ts +10 -0
  27. package/container/agent-runner/src/stream-processor.ts +932 -0
  28. package/container/agent-runner/src/types.ts +75 -0
  29. package/container/agent-runner/src/utils.ts +114 -0
  30. package/container/agent-runner/tsconfig.json +17 -0
  31. package/container/build.sh +28 -0
  32. package/container/entrypoint.sh +64 -0
  33. package/container/skills/agent-browser/SKILL.md +159 -0
  34. package/container/skills/install-skill/SKILL.md +64 -0
  35. package/container/skills/post-test-cleanup/SKILL.md +121 -0
  36. package/dist/.tsbuildinfo +1 -0
  37. package/dist/agent-output-parser.js +459 -0
  38. package/dist/app-root.js +52 -0
  39. package/dist/assistant-meta-footer.js +1 -0
  40. package/dist/auth.js +91 -0
  41. package/dist/billing.js +694 -0
  42. package/dist/channel-prefixes.js +16 -0
  43. package/dist/cli.js +86 -0
  44. package/dist/commands.js +79 -0
  45. package/dist/config.js +120 -0
  46. package/dist/container-runner.js +981 -0
  47. package/dist/daily-summary.js +210 -0
  48. package/dist/db.js +3683 -0
  49. package/dist/dingtalk.js +1347 -0
  50. package/dist/feishu-markdown-style.js +97 -0
  51. package/dist/feishu-streaming-card.js +1875 -0
  52. package/dist/feishu.js +1628 -0
  53. package/dist/file-manager.js +270 -0
  54. package/dist/group-queue.js +1070 -0
  55. package/dist/group-runtime.js +35 -0
  56. package/dist/host-workspace-cwd.js +85 -0
  57. package/dist/im-channel.js +384 -0
  58. package/dist/im-command-utils.js +142 -0
  59. package/dist/im-downloader.js +45 -0
  60. package/dist/im-manager.js +527 -0
  61. package/dist/im-utils.js +53 -0
  62. package/dist/image-detector.js +96 -0
  63. package/dist/index.js +5828 -0
  64. package/dist/logger.js +22 -0
  65. package/dist/mcp-utils.js +66 -0
  66. package/dist/message-attachments.js +69 -0
  67. package/dist/message-notifier.js +36 -0
  68. package/dist/middleware/auth.js +85 -0
  69. package/dist/mount-security.js +315 -0
  70. package/dist/permissions.js +67 -0
  71. package/dist/project-memory.js +6 -0
  72. package/dist/provider-pool.js +189 -0
  73. package/dist/qq.js +826 -0
  74. package/dist/reset-admin.js +42 -0
  75. package/dist/routes/admin.js +543 -0
  76. package/dist/routes/agent-definitions.js +241 -0
  77. package/dist/routes/agents.js +533 -0
  78. package/dist/routes/auth.js +675 -0
  79. package/dist/routes/billing.js +490 -0
  80. package/dist/routes/browse.js +210 -0
  81. package/dist/routes/bug-report.js +387 -0
  82. package/dist/routes/config.js +1868 -0
  83. package/dist/routes/files.js +671 -0
  84. package/dist/routes/groups.js +1367 -0
  85. package/dist/routes/mcp-servers.js +320 -0
  86. package/dist/routes/memory.js +523 -0
  87. package/dist/routes/monitor.js +307 -0
  88. package/dist/routes/skills.js +777 -0
  89. package/dist/routes/tasks.js +509 -0
  90. package/dist/routes/usage.js +64 -0
  91. package/dist/routes/workspace-config.js +458 -0
  92. package/dist/runtime-build.js +112 -0
  93. package/dist/runtime-command-handler.js +189 -0
  94. package/dist/runtime-command-registry.js +1 -0
  95. package/dist/runtime-config.js +1777 -0
  96. package/dist/runtime-identity.js +52 -0
  97. package/dist/schemas.js +590 -0
  98. package/dist/script-runner.js +64 -0
  99. package/dist/sdk-query.js +82 -0
  100. package/dist/skill-utils.js +145 -0
  101. package/dist/sqlite-compat.js +19 -0
  102. package/dist/stream-event.types.js +5 -0
  103. package/dist/streaming-runtime-meta.js +29 -0
  104. package/dist/task-scheduler.js +695 -0
  105. package/dist/task-utils.js +13 -0
  106. package/dist/telegram-pairing.js +59 -0
  107. package/dist/telegram.js +897 -0
  108. package/dist/terminal-manager.js +307 -0
  109. package/dist/tool-step-display.js +1 -0
  110. package/dist/types.js +1 -0
  111. package/dist/utils.js +85 -0
  112. package/dist/web-context.js +161 -0
  113. package/dist/web.js +1377 -0
  114. package/dist/wechat-crypto.js +182 -0
  115. package/dist/wechat.js +589 -0
  116. package/dist/workspace-runtime-reset.js +35 -0
  117. package/package.json +107 -0
  118. package/shared/assistant-meta-footer.ts +127 -0
  119. package/shared/channel-prefixes.ts +16 -0
  120. package/shared/dist/assistant-meta-footer.d.ts +29 -0
  121. package/shared/dist/assistant-meta-footer.js +85 -0
  122. package/shared/dist/channel-prefixes.d.ts +4 -0
  123. package/shared/dist/channel-prefixes.js +16 -0
  124. package/shared/dist/image-detector.d.ts +20 -0
  125. package/shared/dist/image-detector.js +96 -0
  126. package/shared/dist/runtime-command-registry.d.ts +38 -0
  127. package/shared/dist/runtime-command-registry.js +185 -0
  128. package/shared/dist/stream-event.d.ts +65 -0
  129. package/shared/dist/stream-event.js +8 -0
  130. package/shared/dist/tool-step-display.d.ts +4 -0
  131. package/shared/dist/tool-step-display.js +11 -0
  132. package/shared/image-detector.ts +116 -0
  133. package/shared/runtime-command-registry.ts +252 -0
  134. package/shared/stream-event.ts +67 -0
  135. package/shared/tool-step-display.ts +21 -0
  136. package/shared/tsconfig.json +24 -0
  137. package/web/dist/assets/BillingPage-B1wBR_o-.js +52 -0
  138. package/web/dist/assets/ChatPage-6GBZ9nXN.css +32 -0
  139. package/web/dist/assets/ChatPage-BOJcXtaj.js +161 -0
  140. package/web/dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
  141. package/web/dist/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
  142. package/web/dist/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
  143. package/web/dist/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
  144. package/web/dist/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
  145. package/web/dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
  146. package/web/dist/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
  147. package/web/dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
  148. package/web/dist/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
  149. package/web/dist/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
  150. package/web/dist/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
  151. package/web/dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
  152. package/web/dist/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
  153. package/web/dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
  154. package/web/dist/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
  155. package/web/dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
  156. package/web/dist/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
  157. package/web/dist/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
  158. package/web/dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
  159. package/web/dist/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
  160. package/web/dist/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
  161. package/web/dist/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
  162. package/web/dist/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
  163. package/web/dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
  164. package/web/dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
  165. package/web/dist/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
  166. package/web/dist/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
  167. package/web/dist/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
  168. package/web/dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
  169. package/web/dist/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
  170. package/web/dist/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
  171. package/web/dist/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
  172. package/web/dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
  173. package/web/dist/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
  174. package/web/dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
  175. package/web/dist/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
  176. package/web/dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
  177. package/web/dist/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
  178. package/web/dist/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
  179. package/web/dist/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
  180. package/web/dist/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
  181. package/web/dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
  182. package/web/dist/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
  183. package/web/dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
  184. package/web/dist/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
  185. package/web/dist/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
  186. package/web/dist/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
  187. package/web/dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
  188. package/web/dist/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
  189. package/web/dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
  190. package/web/dist/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
  191. package/web/dist/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
  192. package/web/dist/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
  193. package/web/dist/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
  194. package/web/dist/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
  195. package/web/dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
  196. package/web/dist/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
  197. package/web/dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
  198. package/web/dist/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
  199. package/web/dist/assets/SettingsPage-DoY7FoZ_.js +153 -0
  200. package/web/dist/assets/ShareImageDialog-C1ga8b7l.js +22 -0
  201. package/web/dist/assets/TasksPage-CRivnNsx.js +14 -0
  202. package/web/dist/assets/_basePickBy-Bf-bSoS9.js +1 -0
  203. package/web/dist/assets/_baseUniq-zAOaCuKw.js +1 -0
  204. package/web/dist/assets/arc-Dm9mVQ9U.js +1 -0
  205. package/web/dist/assets/architectureDiagram-2XIMDMQ5-BLmzX1wr.js +36 -0
  206. package/web/dist/assets/band-CquvqAHh.js +1 -0
  207. package/web/dist/assets/blockDiagram-WCTKOSBZ-B9pcqm3j.js +132 -0
  208. package/web/dist/assets/c4Diagram-IC4MRINW-Cytx1q3b.js +10 -0
  209. package/web/dist/assets/channel-BOVj73LR.js +1 -0
  210. package/web/dist/assets/channel-meta-CQD0Pei-.js +41 -0
  211. package/web/dist/assets/chunk-4BX2VUAB-0ToDr6RE.js +1 -0
  212. package/web/dist/assets/chunk-55IACEB6-DQDjnXfS.js +1 -0
  213. package/web/dist/assets/chunk-FMBD7UC4-Di8ABm6c.js +15 -0
  214. package/web/dist/assets/chunk-JSJVCQXG-BZQN6rnX.js +1 -0
  215. package/web/dist/assets/chunk-KX2RTZJC-zBbcpaN_.js +1 -0
  216. package/web/dist/assets/chunk-NQ4KR5QH-BCrLoU88.js +220 -0
  217. package/web/dist/assets/chunk-QZHKN3VN-Bqk8juan.js +1 -0
  218. package/web/dist/assets/chunk-WL4C6EOR-D2YX-MHY.js +189 -0
  219. package/web/dist/assets/classDiagram-VBA2DB6C-DUUoMyaK.js +1 -0
  220. package/web/dist/assets/classDiagram-v2-RAHNMMFH-DUUoMyaK.js +1 -0
  221. package/web/dist/assets/clone-BmaCesfa.js +1 -0
  222. package/web/dist/assets/cose-bilkent-S5V4N54A-CTsv6qQA.js +1 -0
  223. package/web/dist/assets/cytoscape.esm-BQaXIfA_.js +331 -0
  224. package/web/dist/assets/dagre-KLK3FWXG-Ci4Jh9nu.js +4 -0
  225. package/web/dist/assets/defaultLocale-DX6XiGOO.js +1 -0
  226. package/web/dist/assets/diagram-E7M64L7V-BFRnfTI2.js +24 -0
  227. package/web/dist/assets/diagram-IFDJBPK2-B7Zhnp0b.js +43 -0
  228. package/web/dist/assets/diagram-P4PSJMXO-BVyP7nwq.js +24 -0
  229. package/web/dist/assets/erDiagram-INFDFZHY-NorKdTOF.js +70 -0
  230. package/web/dist/assets/error-CGD5mp5f.js +1 -0
  231. package/web/dist/assets/flowDiagram-PKNHOUZH-Ch97nABF.js +162 -0
  232. package/web/dist/assets/ganttDiagram-A5KZAMGK-BQ2pLWsy.js +292 -0
  233. package/web/dist/assets/gitGraphDiagram-K3NZZRJ6-bcvnBsD2.js +65 -0
  234. package/web/dist/assets/graph-CeAEckur.js +1 -0
  235. package/web/dist/assets/index-CPnL1_qC.js +768 -0
  236. package/web/dist/assets/index-DVevCbcO.css +10 -0
  237. package/web/dist/assets/infoDiagram-LFFYTUFH-CcsrFdj-.js +2 -0
  238. package/web/dist/assets/init-Dmth1JHB.js +1 -0
  239. package/web/dist/assets/ishikawaDiagram-PHBUUO56-1upyMfHN.js +70 -0
  240. package/web/dist/assets/journeyDiagram-4ABVD52K-CKUi-V0c.js +139 -0
  241. package/web/dist/assets/kanban-definition-K7BYSVSG-DOnQwXfL.js +89 -0
  242. package/web/dist/assets/layout-BmMMqTnJ.js +1 -0
  243. package/web/dist/assets/linear-DiaJloY5.js +1 -0
  244. package/web/dist/assets/mermaid.core-BWLV1B2v.js +254 -0
  245. package/web/dist/assets/mindmap-definition-YRQLILUH-BeAKHVWP.js +68 -0
  246. package/web/dist/assets/ordinal-DILIJJjt.js +1 -0
  247. package/web/dist/assets/pieDiagram-SKSYHLDU-DfiMSfWo.js +30 -0
  248. package/web/dist/assets/quadrantDiagram-337W2JSQ-wZxZOJxd.js +7 -0
  249. package/web/dist/assets/requirementDiagram-Z7DCOOCP-BK4HHm17.js +73 -0
  250. package/web/dist/assets/sankeyDiagram-WA2Y5GQK-BX6t2avX.js +10 -0
  251. package/web/dist/assets/sequenceDiagram-2WXFIKYE-BPQlkbAa.js +145 -0
  252. package/web/dist/assets/sheet-rI0FfB1g.js +6 -0
  253. package/web/dist/assets/sliders-horizontal-CuijWFNK.js +6 -0
  254. package/web/dist/assets/sparkles-BsMYXJoT.js +11 -0
  255. package/web/dist/assets/square-0CqMX1Q3.js +11 -0
  256. package/web/dist/assets/stateDiagram-RAJIS63D-DxkV0Vwd.js +1 -0
  257. package/web/dist/assets/stateDiagram-v2-FVOUBMTO-qLYoiOPe.js +1 -0
  258. package/web/dist/assets/step-D51IIHGA.js +1 -0
  259. package/web/dist/assets/tasks-D8JjBTwx.js +1 -0
  260. package/web/dist/assets/time-O8zIGux3.js +1 -0
  261. package/web/dist/assets/timeline-definition-YZTLITO2-kNp1DyFc.js +61 -0
  262. package/web/dist/assets/treemap-KZPCXAKY-CkrClVhk.js +162 -0
  263. package/web/dist/assets/utils-KGAn0XTg.js +11 -0
  264. package/web/dist/assets/vennDiagram-LZ73GAT5-CgdzEZz4.js +34 -0
  265. package/web/dist/assets/xychartDiagram-JWTSCODW-DfYGPfNB.js +7 -0
  266. package/web/dist/assets/zap-_hKJYy7J.js +6 -0
  267. package/web/dist/favicon.svg +332 -0
  268. package/web/dist/fonts/AlibabaPuHuiTi-3-55-Regular.woff2 +0 -0
  269. package/web/dist/fonts/AlibabaPuHuiTi-3-65-Medium.woff2 +0 -0
  270. package/web/dist/fonts/AlibabaPuHuiTi-3-75-SemiBold.woff2 +0 -0
  271. package/web/dist/fonts/DMSans-latin-ext.woff2 +0 -0
  272. package/web/dist/fonts/DMSans-latin.woff2 +0 -0
  273. package/web/dist/icons/README.md +20 -0
  274. package/web/dist/icons/apple-touch-icon-180.png +0 -0
  275. package/web/dist/icons/icon-128.png +0 -0
  276. package/web/dist/icons/icon-144.png +0 -0
  277. package/web/dist/icons/icon-152.png +0 -0
  278. package/web/dist/icons/icon-192.png +0 -0
  279. package/web/dist/icons/icon-192.svg +332 -0
  280. package/web/dist/icons/icon-384.png +0 -0
  281. package/web/dist/icons/icon-48.png +0 -0
  282. package/web/dist/icons/icon-512-maskable.png +0 -0
  283. package/web/dist/icons/icon-512.png +0 -0
  284. package/web/dist/icons/icon-512.svg +332 -0
  285. package/web/dist/icons/icon-72.png +0 -0
  286. package/web/dist/icons/icon-96.png +0 -0
  287. package/web/dist/icons/loading-logo.svg +332 -0
  288. package/web/dist/icons/logo-1024.png +0 -0
  289. package/web/dist/icons/logo-icon.svg +332 -0
  290. package/web/dist/icons/logo-text.svg +332 -0
  291. package/web/dist/index.html +30 -0
  292. package/web/dist/manifest.webmanifest +1 -0
  293. package/web/dist/registerSW.js +1 -0
  294. package/web/dist/sw.js +1 -0
  295. package/web/dist/workbox-08d6266a.js +1 -0
@@ -0,0 +1,675 @@
1
+ // Authentication routes
2
+ import fs from 'fs';
3
+ import { readFile } from 'fs/promises';
4
+ import path from 'path';
5
+ import crypto from 'crypto';
6
+ import { Hono } from 'hono';
7
+ import { authMiddleware } from '../middleware/auth.js';
8
+ import { getClientIp } from '../utils.js';
9
+ import { DATA_DIR } from '../config.js';
10
+ import { LoginSchema, RegisterSchema, ProfileUpdateSchema, ChangePasswordSchema, } from '../schemas.js';
11
+ import { getUserByUsername, getUserById, createInitialAdminUser, createUserSession, deleteUserSession, deleteUserSessionsByUserId, updateUserFields, getUserSessions, getUserCount, registerUserWithInvite, registerUserWithoutInvite, logAuthEvent, ensureUserHomeGroup, } from '../db.js';
12
+ import { getRegistrationConfig, getEnabledProviders, getFeishuProviderConfigWithSource, getAppearanceConfig, } from '../runtime-config.js';
13
+ import { verifyPassword, hashPassword, generateSessionToken, sessionExpiresAt, checkLoginRateLimit, recordLoginAttempt, clearLoginAttempts, validateUsername, validatePassword, generateUserId, } from '../auth.js';
14
+ import { logger } from '../logger.js';
15
+ import { invalidateSessionCache, invalidateUserSessions, } from '../web-context.js';
16
+ import { SESSION_COOKIE_NAME_SECURE, SESSION_COOKIE_NAME_PLAIN, TRUST_PROXY, } from '../config.js';
17
+ import { getSystemSettings } from '../runtime-config.js';
18
+ const authRoutes = new Hono();
19
+ // --- Helper Functions ---
20
+ /** Detect if the current request arrived over HTTPS (direct or behind proxy) */
21
+ function isSecureRequest(c) {
22
+ if (TRUST_PROXY) {
23
+ const proto = c.req.header('x-forwarded-proto');
24
+ if (proto === 'https')
25
+ return true;
26
+ }
27
+ // Hono / node-server: URL scheme
28
+ try {
29
+ const url = new URL(c.req.url, 'http://localhost');
30
+ if (url.protocol === 'https:')
31
+ return true;
32
+ }
33
+ catch {
34
+ /* ignore */
35
+ }
36
+ return false;
37
+ }
38
+ function getSessionCookieName(secure) {
39
+ return secure ? SESSION_COOKIE_NAME_SECURE : SESSION_COOKIE_NAME_PLAIN;
40
+ }
41
+ export function setSessionCookie(c, token) {
42
+ const secure = isSecureRequest(c);
43
+ const name = getSessionCookieName(secure);
44
+ const secureSuffix = secure ? '; Secure' : '';
45
+ return `${name}=${token}; HttpOnly; SameSite=Strict; Path=/; Max-Age=${30 * 24 * 60 * 60}${secureSuffix}`;
46
+ }
47
+ export function clearSessionCookie(c) {
48
+ const secure = isSecureRequest(c);
49
+ const name = getSessionCookieName(secure);
50
+ const secureSuffix = secure ? '; Secure' : '';
51
+ return `${name}=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0${secureSuffix}`;
52
+ }
53
+ export function isUsernameConflictError(err) {
54
+ return (err instanceof Error &&
55
+ err.message.includes('UNIQUE constraint failed: users.username'));
56
+ }
57
+ export function toUserPublic(u) {
58
+ return {
59
+ id: u.id,
60
+ username: u.username,
61
+ display_name: u.display_name,
62
+ role: u.role,
63
+ status: u.status,
64
+ permissions: u.permissions,
65
+ must_change_password: u.must_change_password,
66
+ disable_reason: u.disable_reason,
67
+ notes: u.notes,
68
+ avatar_emoji: u.avatar_emoji ?? null,
69
+ avatar_color: u.avatar_color ?? null,
70
+ avatar_url: u.avatar_url ?? null,
71
+ ai_name: u.ai_name ?? null,
72
+ ai_avatar_emoji: u.ai_avatar_emoji ?? null,
73
+ ai_avatar_color: u.ai_avatar_color ?? null,
74
+ ai_avatar_url: u.ai_avatar_url ?? null,
75
+ created_at: u.created_at,
76
+ last_login_at: u.last_login_at,
77
+ last_active_at: null,
78
+ deleted_at: u.deleted_at,
79
+ };
80
+ }
81
+ function buildSetupStatus() {
82
+ // Check ALL enabled providers, not just the first one.
83
+ // V3→V4 migration can produce empty providers that sort before real ones,
84
+ // causing getClaudeProviderConfig() (first-match) to return an unconfigured provider.
85
+ const providers = getEnabledProviders();
86
+ const claudeConfigured = providers.some((p) => {
87
+ const hasOfficial = !!p.claudeCodeOauthToken?.trim() ||
88
+ !!p.claudeOAuthCredentials ||
89
+ !!p.anthropicApiKey?.trim();
90
+ const hasThirdParty = !!(p.anthropicBaseUrl?.trim() && p.anthropicAuthToken?.trim());
91
+ return hasOfficial || hasThirdParty;
92
+ });
93
+ const { source: feishuSource } = getFeishuProviderConfigWithSource();
94
+ const feishuConfigured = feishuSource !== 'none';
95
+ return {
96
+ needsSetup: false,
97
+ claudeConfigured,
98
+ feishuConfigured,
99
+ };
100
+ }
101
+ // --- Routes ---
102
+ // Public: check if system is initialized (any user exists)
103
+ authRoutes.get('/status', (c) => {
104
+ const initialized = getUserCount(true) > 0;
105
+ return c.json({ initialized });
106
+ });
107
+ // Public: initial admin setup (only when no users exist)
108
+ authRoutes.post('/setup', async (c) => {
109
+ const body = await c.req.json().catch(() => ({}));
110
+ const { username, password } = body;
111
+ if (!username || !password) {
112
+ return c.json({ error: 'Username and password are required' }, 400);
113
+ }
114
+ const usernameError = validateUsername(username);
115
+ if (usernameError)
116
+ return c.json({ error: usernameError }, 400);
117
+ const passwordError = validatePassword(password);
118
+ if (passwordError)
119
+ return c.json({ error: passwordError }, 400);
120
+ const now = new Date().toISOString();
121
+ const userId = generateUserId();
122
+ const passwordHash = await hashPassword(password);
123
+ const ip = getClientIp(c);
124
+ const ua = c.req.header('user-agent') || null;
125
+ const createResult = createInitialAdminUser({
126
+ id: userId,
127
+ username,
128
+ password_hash: passwordHash,
129
+ display_name: username,
130
+ role: 'admin',
131
+ status: 'active',
132
+ must_change_password: false,
133
+ notes: 'Initial admin (setup wizard)',
134
+ created_at: now,
135
+ updated_at: now,
136
+ });
137
+ if (!createResult.ok) {
138
+ if (createResult.reason === 'already_initialized') {
139
+ return c.json({ error: 'System already initialized' }, 403);
140
+ }
141
+ return c.json({ error: 'Username already taken' }, 400);
142
+ }
143
+ logAuthEvent({
144
+ event_type: 'user_created',
145
+ username,
146
+ actor_username: 'system',
147
+ ip_address: ip,
148
+ user_agent: ua,
149
+ details: { source: 'setup_wizard', role: 'admin' },
150
+ });
151
+ // Create admin home group (web:main, folder=main, host mode)
152
+ try {
153
+ ensureUserHomeGroup(userId, 'admin', username);
154
+ }
155
+ catch (err) {
156
+ logger.warn({ err, userId }, 'Failed to create admin home group during setup');
157
+ }
158
+ // Auto-login
159
+ const token = generateSessionToken();
160
+ createUserSession({
161
+ id: token,
162
+ user_id: userId,
163
+ ip_address: ip,
164
+ user_agent: ua,
165
+ created_at: now,
166
+ expires_at: sessionExpiresAt(),
167
+ last_active_at: now,
168
+ });
169
+ const newUser = getUserById(userId);
170
+ return new Response(JSON.stringify({
171
+ success: true,
172
+ user: toUserPublic(newUser),
173
+ setupStatus: buildSetupStatus(),
174
+ }), {
175
+ status: 201,
176
+ headers: {
177
+ 'Content-Type': 'application/json',
178
+ 'Set-Cookie': setSessionCookie(c, token),
179
+ },
180
+ });
181
+ });
182
+ authRoutes.post('/login', async (c) => {
183
+ const body = await c.req.json().catch(() => ({}));
184
+ const validation = LoginSchema.safeParse(body);
185
+ if (!validation.success) {
186
+ return c.json({ error: 'Invalid credentials' }, 401);
187
+ }
188
+ const { username, password } = validation.data;
189
+ const ip = getClientIp(c);
190
+ const ua = c.req.header('user-agent') || null;
191
+ // Rate limiting
192
+ const { maxLoginAttempts, loginLockoutMinutes } = getSystemSettings();
193
+ const rateCheck = checkLoginRateLimit(username, ip, maxLoginAttempts, loginLockoutMinutes);
194
+ if (!rateCheck.allowed) {
195
+ logAuthEvent({
196
+ event_type: 'login_failed',
197
+ username,
198
+ ip_address: ip,
199
+ user_agent: ua,
200
+ details: { reason: 'rate_limited' },
201
+ });
202
+ return c.json({
203
+ error: `Too many login attempts. Try again in ${rateCheck.retryAfterSeconds}s`,
204
+ }, 429);
205
+ }
206
+ const user = getUserByUsername(username);
207
+ // Constant-time: always run bcrypt compare even if user doesn't exist (prevents timing attacks)
208
+ // 使用运行时生成的合法 bcrypt hash,确保 bcrypt.compare 不会抛异常
209
+ const DUMMY_HASH = '$2b$12$GBXvNon/zJbUI4jtleGnP.YX03zXP5eSXjppo7a3vyWEUK/2YwdP.';
210
+ let passwordMatch;
211
+ try {
212
+ passwordMatch = await verifyPassword(password, user ? user.password_hash : DUMMY_HASH);
213
+ }
214
+ catch {
215
+ // 如果 hash 格式异常,视为不匹配,不泄漏内部错误
216
+ passwordMatch = false;
217
+ }
218
+ if (!user || user.status !== 'active' || !passwordMatch) {
219
+ recordLoginAttempt(username, ip);
220
+ logAuthEvent({
221
+ event_type: 'login_failed',
222
+ username,
223
+ ip_address: ip,
224
+ user_agent: ua,
225
+ details: {
226
+ reason: !user
227
+ ? 'user_not_found'
228
+ : user.status !== 'active'
229
+ ? 'account_inactive'
230
+ : 'wrong_password',
231
+ },
232
+ });
233
+ return c.json({ error: 'Invalid credentials' }, 401);
234
+ }
235
+ // Success — create session
236
+ const token = generateSessionToken();
237
+ const now = new Date().toISOString();
238
+ createUserSession({
239
+ id: token,
240
+ user_id: user.id,
241
+ ip_address: ip,
242
+ user_agent: ua,
243
+ created_at: now,
244
+ expires_at: sessionExpiresAt(),
245
+ last_active_at: now,
246
+ });
247
+ clearLoginAttempts(username, ip);
248
+ updateUserFields(user.id, { last_login_at: now });
249
+ // Ensure user has a home group (backfill for existing users)
250
+ try {
251
+ ensureUserHomeGroup(user.id, user.role, user.username);
252
+ }
253
+ catch (err) {
254
+ // Don't block login if home group creation fails
255
+ logger.warn({ err, userId: user.id }, 'Failed to ensure home group during login');
256
+ }
257
+ logAuthEvent({
258
+ event_type: 'login_success',
259
+ username,
260
+ ip_address: ip,
261
+ user_agent: ua,
262
+ });
263
+ const updatedUser = getUserById(user.id) ?? user;
264
+ const setupStatus = updatedUser.role === 'admin' ? buildSetupStatus() : undefined;
265
+ return new Response(JSON.stringify({
266
+ success: true,
267
+ user: toUserPublic(updatedUser),
268
+ setupStatus,
269
+ }), {
270
+ status: 200,
271
+ headers: {
272
+ 'Content-Type': 'application/json',
273
+ 'Set-Cookie': setSessionCookie(c, token),
274
+ },
275
+ });
276
+ });
277
+ authRoutes.get('/register/status', (c) => {
278
+ // Before initial admin setup, force users through /setup first.
279
+ if (getUserCount(true) === 0) {
280
+ return c.json({
281
+ allowRegistration: false,
282
+ requireInviteCode: true,
283
+ });
284
+ }
285
+ const config = getRegistrationConfig();
286
+ return c.json({
287
+ allowRegistration: config.allowRegistration,
288
+ requireInviteCode: config.requireInviteCode,
289
+ });
290
+ });
291
+ authRoutes.post('/register', async (c) => {
292
+ if (getUserCount(true) === 0) {
293
+ return c.json({ error: '系统尚未初始化,请先完成管理员设置。' }, 403);
294
+ }
295
+ // Check registration switch
296
+ const regConfig = getRegistrationConfig();
297
+ if (!regConfig.allowRegistration) {
298
+ return c.json({ error: '注册功能已关闭' }, 403);
299
+ }
300
+ const body = await c.req.json().catch(() => ({}));
301
+ const validation = RegisterSchema.safeParse(body);
302
+ if (!validation.success) {
303
+ return c.json({ error: 'Invalid request', details: validation.error.format() }, 400);
304
+ }
305
+ const { username, password, display_name, invite_code } = validation.data;
306
+ // If invite code is required but not provided, reject
307
+ if (regConfig.requireInviteCode && !invite_code) {
308
+ return c.json({ error: '需要提供邀请码' }, 400);
309
+ }
310
+ const ip = getClientIp(c);
311
+ const ua = c.req.header('user-agent') || null;
312
+ // IP-based rate limiting for register endpoint
313
+ const { maxLoginAttempts: regMaxAttempts, loginLockoutMinutes: regLockoutMin, } = getSystemSettings();
314
+ const rateCheck = checkLoginRateLimit(`register:${ip}`, ip, regMaxAttempts, regLockoutMin);
315
+ if (!rateCheck.allowed) {
316
+ return c.json({
317
+ error: `Too many registration attempts. Try again in ${rateCheck.retryAfterSeconds}s`,
318
+ }, 429);
319
+ }
320
+ // Validate username format
321
+ const usernameError = validateUsername(username);
322
+ if (usernameError)
323
+ return c.json({ error: usernameError }, 400);
324
+ const passwordError = validatePassword(password);
325
+ if (passwordError)
326
+ return c.json({ error: passwordError }, 400);
327
+ const now = new Date().toISOString();
328
+ const userId = generateUserId();
329
+ const passwordHash = await hashPassword(password);
330
+ // Branch: with invite code or without
331
+ const withInvite = !!invite_code;
332
+ const result = withInvite
333
+ ? registerUserWithInvite({
334
+ id: userId,
335
+ username,
336
+ password_hash: passwordHash,
337
+ display_name: display_name || username,
338
+ invite_code: invite_code,
339
+ created_at: now,
340
+ updated_at: now,
341
+ })
342
+ : registerUserWithoutInvite({
343
+ id: userId,
344
+ username,
345
+ password_hash: passwordHash,
346
+ display_name: display_name || username,
347
+ created_at: now,
348
+ updated_at: now,
349
+ });
350
+ if (!result.ok) {
351
+ recordLoginAttempt(`register:${ip}`, ip);
352
+ if (result.reason === 'username_taken') {
353
+ return c.json({ error: 'Registration failed. Username may already be taken.' }, 400);
354
+ }
355
+ return c.json({ error: 'Invalid or expired invite code' }, 400);
356
+ }
357
+ if (withInvite) {
358
+ logAuthEvent({
359
+ event_type: 'invite_used',
360
+ username,
361
+ ip_address: ip,
362
+ user_agent: ua,
363
+ details: { invite_code: invite_code.slice(0, 8) + '...' },
364
+ });
365
+ }
366
+ logAuthEvent({
367
+ event_type: 'register_success',
368
+ username,
369
+ ip_address: ip,
370
+ user_agent: ua,
371
+ details: { role: result.role, with_invite: withInvite },
372
+ });
373
+ // Create home group for new user
374
+ try {
375
+ ensureUserHomeGroup(userId, result.role, username);
376
+ }
377
+ catch (err) {
378
+ logger.warn({ err, userId }, 'Failed to create home group during registration');
379
+ }
380
+ // Auto-login
381
+ const token = generateSessionToken();
382
+ createUserSession({
383
+ id: token,
384
+ user_id: userId,
385
+ ip_address: ip,
386
+ user_agent: ua,
387
+ created_at: now,
388
+ expires_at: sessionExpiresAt(),
389
+ last_active_at: now,
390
+ });
391
+ const newUser = getUserById(userId);
392
+ return new Response(JSON.stringify({ success: true, user: toUserPublic(newUser) }), {
393
+ status: 201,
394
+ headers: {
395
+ 'Content-Type': 'application/json',
396
+ 'Set-Cookie': setSessionCookie(c, token),
397
+ },
398
+ });
399
+ });
400
+ authRoutes.post('/logout', authMiddleware, (c) => {
401
+ const sessionId = c.get('sessionId');
402
+ deleteUserSession(sessionId);
403
+ invalidateSessionCache(sessionId);
404
+ const user = c.get('user');
405
+ logAuthEvent({
406
+ event_type: 'logout',
407
+ username: user.username,
408
+ ip_address: getClientIp(c),
409
+ });
410
+ return new Response(JSON.stringify({ success: true }), {
411
+ status: 200,
412
+ headers: {
413
+ 'Content-Type': 'application/json',
414
+ 'Set-Cookie': clearSessionCookie(c),
415
+ },
416
+ });
417
+ });
418
+ authRoutes.get('/me', authMiddleware, (c) => {
419
+ const authUser = c.get('user');
420
+ const fullUser = getUserById(authUser.id);
421
+ if (!fullUser)
422
+ return c.json({ error: 'User not found' }, 404);
423
+ const userPublic = toUserPublic(fullUser);
424
+ const appearance = getAppearanceConfig();
425
+ // Admin users get setup status for the onboarding wizard
426
+ if (fullUser.role === 'admin') {
427
+ return c.json({
428
+ user: userPublic,
429
+ appearance,
430
+ setupStatus: buildSetupStatus(),
431
+ });
432
+ }
433
+ return c.json({ user: userPublic, appearance });
434
+ });
435
+ authRoutes.put('/profile', authMiddleware, async (c) => {
436
+ const body = await c.req.json().catch(() => ({}));
437
+ const validation = ProfileUpdateSchema.safeParse(body);
438
+ if (!validation.success) {
439
+ return c.json({ error: 'Invalid request', details: validation.error.format() }, 400);
440
+ }
441
+ const user = c.get('user');
442
+ const fullUser = getUserById(user.id);
443
+ if (!fullUser)
444
+ return c.json({ error: 'User not found' }, 404);
445
+ const updates = {};
446
+ if (validation.data.username !== undefined) {
447
+ const usernameError = validateUsername(validation.data.username);
448
+ if (usernameError)
449
+ return c.json({ error: usernameError }, 400);
450
+ if (validation.data.username !== fullUser.username) {
451
+ const existed = getUserByUsername(validation.data.username);
452
+ if (existed && existed.id !== fullUser.id) {
453
+ return c.json({ error: 'Username already taken' }, 409);
454
+ }
455
+ }
456
+ updates.username = validation.data.username;
457
+ }
458
+ if (validation.data.display_name !== undefined) {
459
+ updates.display_name = validation.data.display_name;
460
+ }
461
+ if (validation.data.avatar_emoji !== undefined) {
462
+ updates.avatar_emoji = validation.data.avatar_emoji;
463
+ }
464
+ if (validation.data.avatar_color !== undefined) {
465
+ updates.avatar_color = validation.data.avatar_color;
466
+ }
467
+ if (validation.data.avatar_url !== undefined) {
468
+ updates.avatar_url = validation.data.avatar_url;
469
+ }
470
+ if (validation.data.ai_name !== undefined) {
471
+ updates.ai_name = validation.data.ai_name;
472
+ }
473
+ if (validation.data.ai_avatar_emoji !== undefined) {
474
+ updates.ai_avatar_emoji = validation.data.ai_avatar_emoji;
475
+ }
476
+ if (validation.data.ai_avatar_color !== undefined) {
477
+ updates.ai_avatar_color = validation.data.ai_avatar_color;
478
+ }
479
+ if (validation.data.ai_avatar_url !== undefined) {
480
+ updates.ai_avatar_url = validation.data.ai_avatar_url;
481
+ }
482
+ if (Object.keys(updates).length === 0) {
483
+ return c.json({ error: 'No fields to update' }, 400);
484
+ }
485
+ try {
486
+ updateUserFields(user.id, updates);
487
+ }
488
+ catch (err) {
489
+ if (isUsernameConflictError(err)) {
490
+ return c.json({ error: 'Username already taken' }, 409);
491
+ }
492
+ throw err;
493
+ }
494
+ const updated = getUserById(user.id);
495
+ logAuthEvent({
496
+ event_type: 'profile_updated',
497
+ username: updated.username,
498
+ actor_username: fullUser.username,
499
+ ip_address: getClientIp(c),
500
+ details: { fields: Object.keys(updates) },
501
+ });
502
+ return c.json({ success: true, user: toUserPublic(updated) });
503
+ });
504
+ authRoutes.put('/password', authMiddleware, async (c) => {
505
+ const body = await c.req.json().catch(() => ({}));
506
+ const validation = ChangePasswordSchema.safeParse(body);
507
+ if (!validation.success) {
508
+ return c.json({ error: 'Invalid request', details: validation.error.format() }, 400);
509
+ }
510
+ const user = c.get('user');
511
+ const fullUser = getUserById(user.id);
512
+ if (!fullUser)
513
+ return c.json({ error: 'User not found' }, 404);
514
+ const match = await verifyPassword(validation.data.current_password, fullUser.password_hash);
515
+ if (!match)
516
+ return c.json({ error: 'Current password is incorrect' }, 401);
517
+ if (validation.data.current_password === validation.data.new_password) {
518
+ return c.json({ error: 'New password must be different from current password' }, 400);
519
+ }
520
+ const passwordError = validatePassword(validation.data.new_password);
521
+ if (passwordError)
522
+ return c.json({ error: passwordError }, 400);
523
+ const newHash = await hashPassword(validation.data.new_password);
524
+ updateUserFields(user.id, {
525
+ password_hash: newHash,
526
+ must_change_password: false,
527
+ });
528
+ // Revoke all existing sessions for this user
529
+ invalidateUserSessions(user.id);
530
+ deleteUserSessionsByUserId(user.id);
531
+ // Create a fresh session for the current request
532
+ const now = new Date().toISOString();
533
+ const ip = getClientIp(c);
534
+ const ua = c.req.header('user-agent') || null;
535
+ const newToken = generateSessionToken();
536
+ createUserSession({
537
+ id: newToken,
538
+ user_id: user.id,
539
+ ip_address: ip,
540
+ user_agent: ua,
541
+ created_at: now,
542
+ expires_at: sessionExpiresAt(),
543
+ last_active_at: now,
544
+ });
545
+ logAuthEvent({
546
+ event_type: 'password_changed',
547
+ username: user.username,
548
+ ip_address: ip,
549
+ details: { cleared_force_change: true, sessions_revoked: true },
550
+ });
551
+ const updated = getUserById(user.id);
552
+ return new Response(JSON.stringify({ success: true, user: toUserPublic(updated) }), {
553
+ status: 200,
554
+ headers: {
555
+ 'Content-Type': 'application/json',
556
+ 'Set-Cookie': setSessionCookie(c, newToken),
557
+ },
558
+ });
559
+ });
560
+ authRoutes.get('/sessions', authMiddleware, (c) => {
561
+ const user = c.get('user');
562
+ const currentSessionId = c.get('sessionId');
563
+ const sessions = getUserSessions(user.id);
564
+ return c.json({
565
+ sessions: sessions.map((s) => ({
566
+ shortId: s.id.slice(0, 8),
567
+ ip_address: s.ip_address,
568
+ user_agent: s.user_agent,
569
+ created_at: s.created_at,
570
+ last_active_at: s.last_active_at,
571
+ is_current: s.id === currentSessionId,
572
+ })),
573
+ });
574
+ });
575
+ authRoutes.delete('/sessions/:id', authMiddleware, (c) => {
576
+ const user = c.get('user');
577
+ const targetId = c.req.param('id');
578
+ const sessions = getUserSessions(user.id);
579
+ // Support both full token and shortId (first 8 chars) for lookup
580
+ const target = sessions.find((s) => s.id === targetId || s.id.slice(0, 8) === targetId);
581
+ if (!target)
582
+ return c.json({ error: 'Session not found' }, 404);
583
+ deleteUserSession(target.id);
584
+ invalidateSessionCache(target.id);
585
+ logAuthEvent({
586
+ event_type: 'session_revoked',
587
+ username: user.username,
588
+ ip_address: getClientIp(c),
589
+ });
590
+ return c.json({ success: true });
591
+ });
592
+ // --- Avatar Upload ---
593
+ const AVATARS_DIR = path.join(DATA_DIR, 'avatars');
594
+ const ALLOWED_AVATAR_TYPES = {
595
+ 'image/jpeg': '.jpg',
596
+ 'image/png': '.png',
597
+ 'image/gif': '.gif',
598
+ 'image/webp': '.webp',
599
+ };
600
+ const MAX_AVATAR_SIZE = 3 * 1024 * 1024; // 3MB
601
+ authRoutes.post('/avatar', authMiddleware, async (c) => {
602
+ const user = c.get('user');
603
+ const contentType = c.req.header('content-type') || '';
604
+ if (!contentType.includes('multipart/form-data')) {
605
+ return c.json({ error: 'Expected multipart/form-data' }, 400);
606
+ }
607
+ const formData = await c.req.formData();
608
+ const file = formData.get('avatar');
609
+ if (!file || !(file instanceof File)) {
610
+ return c.json({ error: 'No avatar file provided' }, 400);
611
+ }
612
+ if (file.size > MAX_AVATAR_SIZE) {
613
+ return c.json({ error: 'File too large (max 3MB)' }, 400);
614
+ }
615
+ const ext = ALLOWED_AVATAR_TYPES[file.type];
616
+ if (!ext) {
617
+ return c.json({ error: 'Unsupported image type. Use jpg, png, gif or webp' }, 400);
618
+ }
619
+ fs.mkdirSync(AVATARS_DIR, { recursive: true });
620
+ // Delete old avatar files for this user
621
+ try {
622
+ const existing = fs
623
+ .readdirSync(AVATARS_DIR)
624
+ .filter((f) => f.startsWith(`${user.id}-`));
625
+ for (const f of existing) {
626
+ fs.unlinkSync(path.join(AVATARS_DIR, f));
627
+ }
628
+ }
629
+ catch {
630
+ /* ignore */
631
+ }
632
+ const filename = `${user.id}-${crypto.randomBytes(4).toString('hex')}${ext}`;
633
+ const filePath = path.join(AVATARS_DIR, filename);
634
+ const buffer = Buffer.from(await file.arrayBuffer());
635
+ const tmpPath = filePath + '.tmp';
636
+ fs.writeFileSync(tmpPath, buffer);
637
+ fs.renameSync(tmpPath, filePath);
638
+ const avatarUrl = `/api/auth/avatars/${filename}`;
639
+ // Update user profile — target=user stores as avatar_url, otherwise ai_avatar_url
640
+ const target = c.req.query('target');
641
+ const field = target === 'user' ? 'avatar_url' : 'ai_avatar_url';
642
+ updateUserFields(user.id, { [field]: avatarUrl });
643
+ const updated = getUserById(user.id);
644
+ return c.json({ success: true, avatarUrl, user: toUserPublic(updated) });
645
+ });
646
+ // Serve avatar files (public, no auth required)
647
+ authRoutes.get('/avatars/:filename', async (c) => {
648
+ const filename = c.req.param('filename');
649
+ // Security: only allow simple filenames (no path traversal)
650
+ if (!filename || /[/\\]/.test(filename) || filename.includes('..')) {
651
+ return c.json({ error: 'Invalid filename' }, 400);
652
+ }
653
+ const filePath = path.join(AVATARS_DIR, filename);
654
+ if (!fs.existsSync(filePath)) {
655
+ return c.json({ error: 'Avatar not found' }, 404);
656
+ }
657
+ const ext = path.extname(filename).toLowerCase();
658
+ const mimeTypes = {
659
+ '.jpg': 'image/jpeg',
660
+ '.jpeg': 'image/jpeg',
661
+ '.png': 'image/png',
662
+ '.gif': 'image/gif',
663
+ '.webp': 'image/webp',
664
+ };
665
+ const contentType = mimeTypes[ext] || 'application/octet-stream';
666
+ const data = await readFile(filePath);
667
+ return new Response(data, {
668
+ status: 200,
669
+ headers: {
670
+ 'Content-Type': contentType,
671
+ 'Cache-Control': 'public, max-age=31536000, immutable',
672
+ },
673
+ });
674
+ });
675
+ export default authRoutes;