mirror of
https://github.com/BradGroux/veritas-kanban.git
synced 2026-10-10 13:47:53 +00:00
perf: reduce polling when WebSocket connected
This commit is contained in:
parent
9422e38cc9
commit
dc01fc13fd
17 changed files with 1050 additions and 44 deletions
|
|
@ -1924,3 +1924,127 @@
|
|||
{"type":"task.created","taskId":"task_20260128_5Fg8MY","project":"project-a","status":"todo","id":"evt_VbM7dejrnzkS","timestamp":"2026-01-28T18:11:48.735Z"}
|
||||
{"type":"task.created","taskId":"task_20260128_IbGo2I","project":"project-b","status":"todo","id":"evt_QFKC1gIjEuL1","timestamp":"2026-01-28T18:11:48.737Z"}
|
||||
{"type":"task.created","taskId":"task_20260128_SzRC_-","status":"todo","id":"evt_ahnWw5Z8IyjY","timestamp":"2026-01-28T18:11:48.754Z"}
|
||||
{"type":"task.created","taskId":"task_20260128_1twS8N","project":"my-project","status":"todo","id":"evt_1YkhhAEt8LgU","timestamp":"2026-01-28T18:13:07.711Z"}
|
||||
{"type":"task.status_changed","taskId":"task_20260126_legacy1","status":"blocked","previousStatus":"review","id":"evt_jTuopYpWs4pG","timestamp":"2026-01-28T18:13:07.713Z"}
|
||||
{"type":"task.created","taskId":"task_20260128_PX8gra","status":"todo","id":"evt_1Y6dqmv491PA","timestamp":"2026-01-28T18:13:07.715Z"}
|
||||
{"type":"task.created","taskId":"task_20260128_lNqqlV","status":"todo","id":"evt_3gCST5_ncV5s","timestamp":"2026-01-28T18:13:07.718Z"}
|
||||
{"type":"task.created","taskId":"task_20260128_-xkYBz","status":"todo","id":"evt_VGLrH9bm-3tU","timestamp":"2026-01-28T18:13:07.722Z"}
|
||||
{"type":"task.created","taskId":"task_20260128_wJwF2O","status":"todo","id":"evt_nAvyd87Uu0gW","timestamp":"2026-01-28T18:13:07.722Z"}
|
||||
{"type":"task.created","taskId":"task_20260128_h9FxdJ","status":"todo","id":"evt_F3IGOcZCc8LF","timestamp":"2026-01-28T18:13:07.725Z"}
|
||||
{"type":"task.status_changed","taskId":"task_20260128_h9FxdJ","status":"in-progress","previousStatus":"todo","id":"evt_iTLyEaIfnztI","timestamp":"2026-01-28T18:13:07.727Z"}
|
||||
{"type":"task.created","taskId":"task_20260128_xpsxJy","status":"todo","id":"evt_ozf2KZNCYDYz","timestamp":"2026-01-28T18:13:07.728Z"}
|
||||
{"type":"task.status_changed","taskId":"task_20260128_xpsxJy","status":"blocked","previousStatus":"todo","id":"evt_QwE_3RuIjm8S","timestamp":"2026-01-28T18:13:07.729Z"}
|
||||
{"type":"task.created","taskId":"task_20260128_p5lUqc","status":"todo","id":"evt_j2LiP0h7nVMf","timestamp":"2026-01-28T18:13:07.731Z"}
|
||||
{"type":"task.status_changed","taskId":"task_20260128_p5lUqc","status":"done","previousStatus":"todo","id":"evt_jdRxdd72cJLd","timestamp":"2026-01-28T18:13:07.733Z"}
|
||||
{"type":"task.status_changed","taskId":"task_20260128_-xkYBz","status":"in-progress","previousStatus":"todo","id":"evt_d_9X6Ifdd825","timestamp":"2026-01-28T18:13:07.736Z"}
|
||||
{"type":"task.status_changed","taskId":"task_20260126_idem1","status":"blocked","previousStatus":"review","id":"evt_Y07H426v803R","timestamp":"2026-01-28T18:13:07.739Z"}
|
||||
{"type":"task.created","taskId":"task_20260128_VhY67Y","status":"todo","id":"evt_n26dneTNdcYE","timestamp":"2026-01-28T18:13:07.741Z"}
|
||||
{"type":"task.created","taskId":"task_20260128_WIBJ0_","status":"todo","id":"evt_9ccXuiXxZtIu","timestamp":"2026-01-28T18:13:07.747Z"}
|
||||
{"type":"task.status_changed","taskId":"task_20260126_multi1","status":"blocked","previousStatus":"review","id":"evt_r5CqZYt_gG10","timestamp":"2026-01-28T18:13:07.749Z"}
|
||||
{"type":"task.status_changed","taskId":"task_20260126_multi2","status":"blocked","previousStatus":"review","id":"evt__gV2YdsZLzlj","timestamp":"2026-01-28T18:13:07.751Z"}
|
||||
{"type":"task.created","taskId":"task_20260128_tRGga4","status":"todo","id":"evt_zpIXsnSFhv2w","timestamp":"2026-01-28T18:13:07.836Z"}
|
||||
{"type":"task.created","taskId":"task_20260128_Qk8MFR","status":"todo","id":"evt_H2pZ5oAPzooV","timestamp":"2026-01-28T18:13:07.845Z"}
|
||||
{"type":"task.created","taskId":"task_20260128_vYKzfj","status":"todo","id":"evt_UXNi1INfakC2","timestamp":"2026-01-28T18:13:07.867Z"}
|
||||
{"type":"task.created","taskId":"task_20260128_b5T-VN","status":"todo","id":"evt_Z39Jt1K-pZmE","timestamp":"2026-01-28T18:13:07.870Z"}
|
||||
{"type":"task.restored","taskId":"task_20260126_arch1","status":"done","id":"evt_vrd1Y3SYCD3I","timestamp":"2026-01-28T18:13:07.872Z"}
|
||||
{"type":"task.created","taskId":"task_20260128_2XQUVT","status":"todo","id":"evt_61oB6TzzEG5B","timestamp":"2026-01-28T18:13:07.873Z"}
|
||||
{"type":"task.status_changed","taskId":"task_20260126_arch1","status":"blocked","previousStatus":"done","id":"evt_NsWkurVtD1qq","timestamp":"2026-01-28T18:13:07.873Z"}
|
||||
{"type":"task.archived","taskId":"task_20260128_2XQUVT","status":"todo","id":"evt_JJ-R4ilZUZhw","timestamp":"2026-01-28T18:13:07.877Z"}
|
||||
{"type":"task.archived","taskId":"task_20260126_arch1","status":"blocked","id":"evt_EaroJFxqMFfc","timestamp":"2026-01-28T18:13:07.879Z"}
|
||||
{"type":"task.created","taskId":"task_20260128_fWIYj0","status":"todo","id":"evt_KeSgR5stN48Y","timestamp":"2026-01-28T18:13:07.886Z"}
|
||||
{"type":"task.created","taskId":"task_20260128_1B5DCZ","status":"todo","id":"evt_pYvXISeU5zGs","timestamp":"2026-01-28T18:13:07.886Z"}
|
||||
{"type":"task.created","taskId":"task_20260128_dgUJYs","project":"test-project","status":"todo","id":"evt_mRO0GRB1hzxE","timestamp":"2026-01-28T18:13:07.911Z"}
|
||||
{"type":"task.created","taskId":"task_20260128_XoDXLI","status":"todo","id":"evt_MRAjGvunTdpT","timestamp":"2026-01-28T18:13:07.917Z"}
|
||||
{"type":"task.created","taskId":"task_20260128_ZQk99q","status":"todo","id":"evt_ehHVfHvgmCFW","timestamp":"2026-01-28T18:13:07.919Z"}
|
||||
{"type":"task.created","taskId":"task_20260128_f2HW7B","status":"todo","id":"evt_JQuZdtugh58k","timestamp":"2026-01-28T18:13:07.926Z"}
|
||||
{"type":"task.created","taskId":"task_20260128_GoUH1E","status":"todo","id":"evt_GLcQl_tnNflN","timestamp":"2026-01-28T18:13:07.935Z"}
|
||||
{"type":"task.created","taskId":"task_20260128_cpT8ln","status":"todo","id":"evt_aLfCsm8UeMrA","timestamp":"2026-01-28T18:13:07.943Z"}
|
||||
{"type":"task.created","taskId":"task_20260128_73sBkn","status":"todo","id":"evt_IDRWSQ37A-qq","timestamp":"2026-01-28T18:13:07.960Z"}
|
||||
{"type":"task.created","taskId":"task_20260128_mtOBb0","status":"todo","id":"evt_3IndT481otWB","timestamp":"2026-01-28T18:13:07.963Z"}
|
||||
{"type":"task.status_changed","taskId":"task_20260128_mtOBb0","status":"in-progress","previousStatus":"todo","id":"evt_au_zoT6ZX_kF","timestamp":"2026-01-28T18:13:07.967Z"}
|
||||
{"type":"task.created","taskId":"task_20260128_Co2xrP","status":"todo","id":"evt_IcF5Oe1D4wH-","timestamp":"2026-01-28T18:13:07.970Z"}
|
||||
{"type":"task.created","taskId":"task_20260128_cWjUQ4","status":"todo","id":"evt_ucEyQGnUYsE9","timestamp":"2026-01-28T18:13:07.973Z"}
|
||||
{"type":"task.created","taskId":"task_20260128_DObBpV","status":"todo","id":"evt_EAvC7F8eDqcN","timestamp":"2026-01-28T18:13:07.977Z"}
|
||||
{"type":"task.created","taskId":"task_20260128_ySrQGa","status":"todo","id":"evt_9johurgUBbZk","timestamp":"2026-01-28T18:13:07.980Z"}
|
||||
{"type":"task.created","taskId":"task_20260128_D5k0pt","status":"todo","id":"evt_-gIJvfMG4yDl","timestamp":"2026-01-28T18:13:07.989Z"}
|
||||
{"type":"task.created","taskId":"task_20260128_Wbn7tr","status":"todo","id":"evt_1d4-ma4gpkhV","timestamp":"2026-01-28T18:13:08.001Z"}
|
||||
{"type":"task.created","taskId":"task_20260128_6F7cGf","status":"todo","id":"evt_zvgVNSwB9DUz","timestamp":"2026-01-28T18:13:08.003Z"}
|
||||
{"type":"task.created","taskId":"task_20260128_fOgnpC","status":"todo","id":"evt_e71cq88g54oK","timestamp":"2026-01-28T18:13:08.012Z"}
|
||||
{"type":"task.created","taskId":"task_20260128_4S4SPO","status":"todo","id":"evt_BZxpjZiurWXV","timestamp":"2026-01-28T18:13:08.017Z"}
|
||||
{"type":"task.created","taskId":"task_20260128__5K6xZ","status":"todo","id":"evt_a8tmji7KplsD","timestamp":"2026-01-28T18:13:08.025Z"}
|
||||
{"type":"task.created","taskId":"task_20260128_52TXy5","status":"todo","id":"evt_0FFceJNBugtC","timestamp":"2026-01-28T18:13:08.025Z"}
|
||||
{"type":"task.created","taskId":"task_20260128_OrQbqS","status":"todo","id":"evt_KSYu8oP67cDr","timestamp":"2026-01-28T18:13:08.031Z"}
|
||||
{"type":"task.created","taskId":"task_20260128_df1NuG","status":"todo","id":"evt_4SMHWenK30ch","timestamp":"2026-01-28T18:13:08.044Z"}
|
||||
{"type":"task.created","taskId":"task_20260128_wgEgfy","status":"todo","id":"evt_kRMwvCWyzYqq","timestamp":"2026-01-28T18:13:08.046Z"}
|
||||
{"type":"task.created","taskId":"task_20260128_PNOZfo","status":"todo","id":"evt_Fg6rJcQapGqJ","timestamp":"2026-01-28T18:13:08.059Z"}
|
||||
{"type":"task.created","taskId":"task_20260128_FqGMNI","status":"todo","id":"evt_UcugEVUUfI3X","timestamp":"2026-01-28T18:13:08.061Z"}
|
||||
{"type":"task.created","taskId":"task_20260128_c2fskS","status":"todo","id":"evt_YlrJA4j7AV-P","timestamp":"2026-01-28T18:13:08.061Z"}
|
||||
{"type":"task.created","taskId":"task_20260128_HRlN4h","status":"todo","id":"evt_BPlRvHBv7IzS","timestamp":"2026-01-28T18:13:08.062Z"}
|
||||
{"type":"task.created","taskId":"task_20260128_5-ZVns","status":"todo","id":"evt_ME8EZ_cspX5r","timestamp":"2026-01-28T18:13:08.066Z"}
|
||||
{"type":"task.created","taskId":"task_20260128_xZj7Hw","status":"todo","id":"evt_hcjkkXBw9PTM","timestamp":"2026-01-28T18:13:08.084Z"}
|
||||
{"type":"task.created","taskId":"task_20260128_ho-DKA","project":"project-a","status":"todo","id":"evt__BGkMdW8lVyC","timestamp":"2026-01-28T18:13:08.093Z"}
|
||||
{"type":"task.created","taskId":"task_20260128_lgHk30","project":"project-a","status":"todo","id":"evt_JB0zlOofw2nk","timestamp":"2026-01-28T18:13:08.094Z"}
|
||||
{"type":"task.created","taskId":"task_20260128_eNMBYI","project":"project-b","status":"todo","id":"evt_kYNys0q70rMi","timestamp":"2026-01-28T18:13:08.094Z"}
|
||||
{"type":"task.created","taskId":"task_20260128_Xn8U9C","status":"todo","id":"evt_f7iD8X8xS8ZM","timestamp":"2026-01-28T18:13:08.100Z"}
|
||||
{"type":"task.status_changed","taskId":"task_20260126_legacy1","status":"blocked","previousStatus":"review","id":"evt_1-u6xA8W32gr","timestamp":"2026-01-28T18:13:31.988Z"}
|
||||
{"type":"task.created","taskId":"task_20260128_IZKUeS","status":"todo","id":"evt_YqC2bd7TIliM","timestamp":"2026-01-28T18:13:31.996Z"}
|
||||
{"type":"task.created","taskId":"task_20260128_G0mg-Q","status":"todo","id":"evt_G7yQygRBFJVv","timestamp":"2026-01-28T18:13:32.000Z"}
|
||||
{"type":"task.created","taskId":"task_20260128_wH-Pj3","project":"my-project","status":"todo","id":"evt_ogvySyMcxYjL","timestamp":"2026-01-28T18:13:32.001Z"}
|
||||
{"type":"task.status_changed","taskId":"task_20260128_G0mg-Q","status":"in-progress","previousStatus":"todo","id":"evt_BpJM9BohNy4P","timestamp":"2026-01-28T18:13:32.010Z"}
|
||||
{"type":"task.created","taskId":"task_20260128_-sF6IQ","status":"todo","id":"evt_XDfm30O24KfX","timestamp":"2026-01-28T18:13:32.012Z"}
|
||||
{"type":"task.created","taskId":"task_20260128_L9vdmg","status":"todo","id":"evt_YICOYF_8xBzD","timestamp":"2026-01-28T18:13:32.013Z"}
|
||||
{"type":"task.status_changed","taskId":"task_20260128_-sF6IQ","status":"blocked","previousStatus":"todo","id":"evt_TtbUTpyChexY","timestamp":"2026-01-28T18:13:32.016Z"}
|
||||
{"type":"task.created","taskId":"task_20260128_x_nCMc","status":"todo","id":"evt_RGWDo906pI5o","timestamp":"2026-01-28T18:13:32.018Z"}
|
||||
{"type":"task.created","taskId":"task_20260128_g0GaeW","status":"todo","id":"evt_RCt1NJCCa1WW","timestamp":"2026-01-28T18:13:32.019Z"}
|
||||
{"type":"task.status_changed","taskId":"task_20260128_x_nCMc","status":"done","previousStatus":"todo","id":"evt_4YW2HQpOyuwl","timestamp":"2026-01-28T18:13:32.021Z"}
|
||||
{"type":"task.created","taskId":"task_20260128_4DIfix","status":"todo","id":"evt_oI_Am1QldM_n","timestamp":"2026-01-28T18:13:32.024Z"}
|
||||
{"type":"task.status_changed","taskId":"task_20260126_idem1","status":"blocked","previousStatus":"review","id":"evt_zIMLOg_4EGgH","timestamp":"2026-01-28T18:13:32.027Z"}
|
||||
{"type":"task.status_changed","taskId":"task_20260128_4DIfix","status":"in-progress","previousStatus":"todo","id":"evt_4N-1hXs7u9zM","timestamp":"2026-01-28T18:13:32.037Z"}
|
||||
{"type":"task.status_changed","taskId":"task_20260126_multi1","status":"blocked","previousStatus":"review","id":"evt_nuSkwtGGONbj","timestamp":"2026-01-28T18:13:32.038Z"}
|
||||
{"type":"task.status_changed","taskId":"task_20260126_multi2","status":"blocked","previousStatus":"review","id":"evt_SM9Q5Q7Bsf1f","timestamp":"2026-01-28T18:13:32.040Z"}
|
||||
{"type":"task.created","taskId":"task_20260128_3K42Kj","status":"todo","id":"evt_U-wcQ5AAyzxg","timestamp":"2026-01-28T18:13:32.040Z"}
|
||||
{"type":"task.created","taskId":"task_20260128_2yUawv","status":"todo","id":"evt_O4urPNi-lzHu","timestamp":"2026-01-28T18:13:32.042Z"}
|
||||
{"type":"task.created","taskId":"task_20260128_XqerKC","status":"todo","id":"evt_EpKYJSlOlV_q","timestamp":"2026-01-28T18:13:32.045Z"}
|
||||
{"type":"task.created","taskId":"task_20260128_A5zP28","status":"todo","id":"evt_ySLT4jl8jOE0","timestamp":"2026-01-28T18:13:32.049Z"}
|
||||
{"type":"task.created","taskId":"task_20260128_UKRCA3","status":"todo","id":"evt_IssNkIXUljnA","timestamp":"2026-01-28T18:13:32.050Z"}
|
||||
{"type":"task.created","taskId":"task_20260128_jJ2Z0B","status":"todo","id":"evt_qLda7Hmh_hGp","timestamp":"2026-01-28T18:13:32.060Z"}
|
||||
{"type":"task.created","taskId":"task_20260128_XbALCy","status":"todo","id":"evt_93CX-oX1z_mM","timestamp":"2026-01-28T18:13:32.073Z"}
|
||||
{"type":"task.created","taskId":"task_20260128_CbHV0F","project":"test-project","status":"todo","id":"evt_iskALPPwg85G","timestamp":"2026-01-28T18:13:32.080Z"}
|
||||
{"type":"task.created","taskId":"task_20260128_VRyLXK","status":"todo","id":"evt_maZ0iFgLoilm","timestamp":"2026-01-28T18:13:32.085Z"}
|
||||
{"type":"task.created","taskId":"task_20260128_vnBSED","status":"todo","id":"evt_wUM1iPA90zLl","timestamp":"2026-01-28T18:13:32.093Z"}
|
||||
{"type":"task.created","taskId":"task_20260128_KrEFde","status":"todo","id":"evt_T5XMqddVjeOR","timestamp":"2026-01-28T18:13:32.098Z"}
|
||||
{"type":"task.created","taskId":"task_20260128_w9Vgbf","status":"todo","id":"evt_m3SxtXfvw6BN","timestamp":"2026-01-28T18:13:32.099Z"}
|
||||
{"type":"task.status_changed","taskId":"task_20260128_w9Vgbf","status":"in-progress","previousStatus":"todo","id":"evt_nMHMu2Ue1Zjb","timestamp":"2026-01-28T18:13:32.103Z"}
|
||||
{"type":"task.created","taskId":"task_20260128_BiCx67","status":"todo","id":"evt_2r4B83pYzP9T","timestamp":"2026-01-28T18:13:32.108Z"}
|
||||
{"type":"task.created","taskId":"task_20260128_g0Wapj","status":"todo","id":"evt_Q_lFLM4B2NJI","timestamp":"2026-01-28T18:13:32.111Z"}
|
||||
{"type":"task.created","taskId":"task_20260128_Kc1Vxe","status":"todo","id":"evt_-UmgtPjhH_xn","timestamp":"2026-01-28T18:13:32.112Z"}
|
||||
{"type":"task.created","taskId":"task_20260128_mHGW1x","status":"todo","id":"evt_27qJUvb3iDoE","timestamp":"2026-01-28T18:13:32.114Z"}
|
||||
{"type":"task.created","taskId":"task_20260128__0c2EH","status":"todo","id":"evt_6vFz-90N5Pm4","timestamp":"2026-01-28T18:13:32.116Z"}
|
||||
{"type":"task.created","taskId":"task_20260128_MTc-_U","status":"todo","id":"evt_rB-QqQsqylRl","timestamp":"2026-01-28T18:13:32.119Z"}
|
||||
{"type":"task.created","taskId":"task_20260128_oVhWKQ","status":"todo","id":"evt_k0ri5T2Gin5R","timestamp":"2026-01-28T18:13:32.127Z"}
|
||||
{"type":"task.restored","taskId":"task_20260126_arch1","status":"done","id":"evt_QvyZh5CMapqJ","timestamp":"2026-01-28T18:13:32.132Z"}
|
||||
{"type":"task.status_changed","taskId":"task_20260126_arch1","status":"blocked","previousStatus":"done","id":"evt_4zsl0I27LAtn","timestamp":"2026-01-28T18:13:32.133Z"}
|
||||
{"type":"task.archived","taskId":"task_20260126_arch1","status":"blocked","id":"evt_y6kxmxT6W3AQ","timestamp":"2026-01-28T18:13:32.134Z"}
|
||||
{"type":"task.created","taskId":"task_20260128_z989H3","status":"todo","id":"evt_5i0Yf4pWSUpU","timestamp":"2026-01-28T18:13:32.135Z"}
|
||||
{"type":"task.archived","taskId":"task_20260128_z989H3","status":"todo","id":"evt_GJm4c81Lps96","timestamp":"2026-01-28T18:13:32.141Z"}
|
||||
{"type":"task.created","taskId":"task_20260128_zyntE1","status":"todo","id":"evt_55GX5Wpcvpk3","timestamp":"2026-01-28T18:13:32.136Z"}
|
||||
{"type":"task.created","taskId":"task_20260128_KZBkGs","status":"todo","id":"evt_FApgBnyExeME","timestamp":"2026-01-28T18:13:32.157Z"}
|
||||
{"type":"task.created","taskId":"task_20260128_mnxKPC","status":"todo","id":"evt_m9pJgUaq-2qD","timestamp":"2026-01-28T18:13:32.167Z"}
|
||||
{"type":"task.created","taskId":"task_20260128_CCIIpq","status":"todo","id":"evt_MatLUjULxJyH","timestamp":"2026-01-28T18:13:32.178Z"}
|
||||
{"type":"task.created","taskId":"task_20260128_4_vYTr","status":"todo","id":"evt_w5tSV_nJfQl5","timestamp":"2026-01-28T18:13:32.189Z"}
|
||||
{"type":"task.created","taskId":"task_20260128_nO1rMe","status":"todo","id":"evt_Lk46URHFn9pA","timestamp":"2026-01-28T18:13:32.193Z"}
|
||||
{"type":"task.created","taskId":"task_20260128_I4RF_H","status":"todo","id":"evt_sR2pxAuUwY2n","timestamp":"2026-01-28T18:13:32.193Z"}
|
||||
{"type":"task.created","taskId":"task_20260128_JSTQt7","status":"todo","id":"evt_neKXAieeMDOR","timestamp":"2026-01-28T18:13:32.197Z"}
|
||||
{"type":"task.created","taskId":"task_20260128_bJBPXU","status":"todo","id":"evt_VHznaPZPwfi9","timestamp":"2026-01-28T18:13:32.197Z"}
|
||||
{"type":"task.created","taskId":"task_20260128_DyQvNm","status":"todo","id":"evt_n3CCr2KID9L0","timestamp":"2026-01-28T18:13:32.216Z"}
|
||||
{"type":"task.created","taskId":"task_20260128_YyHIj5","status":"todo","id":"evt_T434wB581mJD","timestamp":"2026-01-28T18:13:32.231Z"}
|
||||
{"type":"task.created","taskId":"task_20260128_L31E8W","status":"todo","id":"evt_EsH9roQgmaIh","timestamp":"2026-01-28T18:13:32.231Z"}
|
||||
{"type":"task.created","taskId":"task_20260128_QoEyv9","status":"todo","id":"evt_kKFRJbFZdoyr","timestamp":"2026-01-28T18:13:32.245Z"}
|
||||
{"type":"task.created","taskId":"task_20260128_iYxvkR","status":"todo","id":"evt_-HKnSEGUo0SF","timestamp":"2026-01-28T18:13:32.264Z"}
|
||||
{"type":"task.created","taskId":"task_20260128_4iduyC","status":"todo","id":"evt_rJpwk6dIsQ-L","timestamp":"2026-01-28T18:13:32.275Z"}
|
||||
{"type":"task.created","taskId":"task_20260128_JFJ759","status":"todo","id":"evt_VINcLnfiTKcs","timestamp":"2026-01-28T18:13:32.280Z"}
|
||||
{"type":"task.created","taskId":"task_20260128_dR2GXp","status":"todo","id":"evt_7gQSday5hoZo","timestamp":"2026-01-28T18:13:32.298Z"}
|
||||
{"type":"task.created","taskId":"task_20260128_XBG9V8","project":"project-a","status":"todo","id":"evt_ZsiWd2Y9xud7","timestamp":"2026-01-28T18:13:32.315Z"}
|
||||
{"type":"task.created","taskId":"task_20260128_72m3my","project":"project-a","status":"todo","id":"evt_xShT6SX1XE-v","timestamp":"2026-01-28T18:13:32.316Z"}
|
||||
{"type":"task.created","taskId":"task_20260128_yWBVeq","project":"project-b","status":"todo","id":"evt_OfvULj8k1OrI","timestamp":"2026-01-28T18:13:32.317Z"}
|
||||
{"type":"task.created","taskId":"task_20260128_y2H5B2","status":"todo","id":"evt_zHQ_4Frvn-XO","timestamp":"2026-01-28T18:13:32.356Z"}
|
||||
|
|
|
|||
334
pnpm-lock.yaml
generated
334
pnpm-lock.yaml
generated
|
|
@ -40,7 +40,7 @@ importers:
|
|||
version: 5.9.3
|
||||
vitest:
|
||||
specifier: ^4.0.18
|
||||
version: 4.0.18(@types/node@22.19.7)(jiti@1.21.7)(tsx@4.21.0)
|
||||
version: 4.0.18(@types/node@22.19.7)(jiti@1.21.7)(jsdom@27.4.0(@noble/hashes@1.8.0))(tsx@4.21.0)
|
||||
|
||||
cli:
|
||||
dependencies:
|
||||
|
|
@ -312,6 +312,9 @@ importers:
|
|||
eslint:
|
||||
specifier: ^9.17.0
|
||||
version: 9.39.2(jiti@1.21.7)
|
||||
jsdom:
|
||||
specifier: ^27.4.0
|
||||
version: 27.4.0(@noble/hashes@1.8.0)
|
||||
postcss:
|
||||
specifier: ^8.4.49
|
||||
version: 8.5.6
|
||||
|
|
@ -327,10 +330,22 @@ importers:
|
|||
|
||||
packages:
|
||||
|
||||
'@acemir/cssom@0.9.31':
|
||||
resolution: {integrity: sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==}
|
||||
|
||||
'@alloc/quick-lru@5.2.0':
|
||||
resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
'@asamuzakjp/css-color@4.1.1':
|
||||
resolution: {integrity: sha512-B0Hv6G3gWGMn0xKJ0txEi/jM5iFpT3MfDxmhZFb4W047GvytCf1DHQ1D69W3zHI4yWe2aTZAA0JnbMZ7Xc8DuQ==}
|
||||
|
||||
'@asamuzakjp/dom-selector@6.7.6':
|
||||
resolution: {integrity: sha512-hBaJER6A9MpdG3WgdlOolHmbOYvSk46y7IQN/1+iqiCuUu6iWdQrs9DGKF8ocqsEqWujWf/V7b7vaDgiUmIvUg==}
|
||||
|
||||
'@asamuzakjp/nwsapi@2.3.9':
|
||||
resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==}
|
||||
|
||||
'@babel/code-frame@7.28.6':
|
||||
resolution: {integrity: sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
|
@ -417,6 +432,37 @@ packages:
|
|||
'@borewit/text-codec@0.2.1':
|
||||
resolution: {integrity: sha512-k7vvKPbf7J2fZ5klGRD9AeKfUvojuZIQ3BT5u7Jfv+puwXkUBUT5PVyMDfJZpy30CBDXGMgw7fguK/lpOMBvgw==}
|
||||
|
||||
'@csstools/color-helpers@5.1.0':
|
||||
resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@csstools/css-calc@2.1.4':
|
||||
resolution: {integrity: sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==}
|
||||
engines: {node: '>=18'}
|
||||
peerDependencies:
|
||||
'@csstools/css-parser-algorithms': ^3.0.5
|
||||
'@csstools/css-tokenizer': ^3.0.4
|
||||
|
||||
'@csstools/css-color-parser@3.1.0':
|
||||
resolution: {integrity: sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==}
|
||||
engines: {node: '>=18'}
|
||||
peerDependencies:
|
||||
'@csstools/css-parser-algorithms': ^3.0.5
|
||||
'@csstools/css-tokenizer': ^3.0.4
|
||||
|
||||
'@csstools/css-parser-algorithms@3.0.5':
|
||||
resolution: {integrity: sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==}
|
||||
engines: {node: '>=18'}
|
||||
peerDependencies:
|
||||
'@csstools/css-tokenizer': ^3.0.4
|
||||
|
||||
'@csstools/css-syntax-patches-for-csstree@1.0.26':
|
||||
resolution: {integrity: sha512-6boXK0KkzT5u5xOgF6TKB+CLq9SOpEGmkZw0g5n9/7yg85wab3UzSxB8TxhLJ31L4SGJ6BCFRw/iftTha1CJXA==}
|
||||
|
||||
'@csstools/css-tokenizer@3.0.4':
|
||||
resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@dnd-kit/accessibility@3.1.1':
|
||||
resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==}
|
||||
peerDependencies:
|
||||
|
|
@ -789,6 +835,15 @@ packages:
|
|||
resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
|
||||
'@exodus/bytes@1.10.0':
|
||||
resolution: {integrity: sha512-tf8YdcbirXdPnJ+Nd4UN1EXnz+IP2DI45YVEr3vvzcVTOyrApkmIB4zvOQVd3XPr7RXnfBtAx+PXImXOIU0Ajg==}
|
||||
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
|
||||
peerDependencies:
|
||||
'@noble/hashes': ^1.8.0 || ^2.0.0
|
||||
peerDependenciesMeta:
|
||||
'@noble/hashes':
|
||||
optional: true
|
||||
|
||||
'@fast-csv/format@4.3.5':
|
||||
resolution: {integrity: sha512-8iRn6QF3I8Ak78lNAa+Gdl5MJJBM5vRHivFtMRUWINdevNo00K7OXxS2PshawLKTejVwieIlPmK5YlLu6w4u8A==}
|
||||
|
||||
|
|
@ -1762,6 +1817,10 @@ packages:
|
|||
engines: {node: '>=0.4.0'}
|
||||
hasBin: true
|
||||
|
||||
agent-base@7.1.4:
|
||||
resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==}
|
||||
engines: {node: '>= 14'}
|
||||
|
||||
ajv-formats@3.0.1:
|
||||
resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==}
|
||||
peerDependencies:
|
||||
|
|
@ -1892,6 +1951,9 @@ packages:
|
|||
resolution: {integrity: sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==}
|
||||
engines: {node: '>= 18'}
|
||||
|
||||
bidi-js@1.0.3:
|
||||
resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==}
|
||||
|
||||
big-integer@1.6.52:
|
||||
resolution: {integrity: sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==}
|
||||
engines: {node: '>=0.6'}
|
||||
|
|
@ -2115,11 +2177,19 @@ packages:
|
|||
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
||||
engines: {node: '>= 8'}
|
||||
|
||||
css-tree@3.1.0:
|
||||
resolution: {integrity: sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==}
|
||||
engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0}
|
||||
|
||||
cssesc@3.0.0:
|
||||
resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==}
|
||||
engines: {node: '>=4'}
|
||||
hasBin: true
|
||||
|
||||
cssstyle@5.3.7:
|
||||
resolution: {integrity: sha512-7D2EPVltRrsTkhpQmksIu+LxeWAIEk6wRDMJ1qljlv+CKHJM+cJLlfhWIzNA44eAsHXSNe3+vO6DW1yCYx8SuQ==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
csstype@3.2.3:
|
||||
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
|
||||
|
||||
|
|
@ -2167,6 +2237,10 @@ packages:
|
|||
resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
data-urls@6.0.1:
|
||||
resolution: {integrity: sha512-euIQENZg6x8mj3fO6o9+fOW8MimUI4PpD/fZBhJfeioZVy9TUpM4UY7KjQNVZFlqwJ0UdzRDzkycB997HEq1BQ==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
data-view-buffer@1.0.2:
|
||||
resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
|
@ -2202,6 +2276,9 @@ packages:
|
|||
decimal.js-light@2.5.1:
|
||||
resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==}
|
||||
|
||||
decimal.js@10.6.0:
|
||||
resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==}
|
||||
|
||||
deep-is@0.1.4:
|
||||
resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
|
||||
|
||||
|
|
@ -2280,6 +2357,10 @@ packages:
|
|||
end-of-stream@1.4.5:
|
||||
resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==}
|
||||
|
||||
entities@6.0.1:
|
||||
resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==}
|
||||
engines: {node: '>=0.12'}
|
||||
|
||||
es-abstract@1.24.1:
|
||||
resolution: {integrity: sha512-zHXBLhP+QehSSbsS9Pt23Gg964240DPd6QCf8WpkqEXxQ7fhdZzYsocOr5u7apWonsS5EjZDmTF+/slGMyasvw==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
|
@ -2664,10 +2745,22 @@ packages:
|
|||
resolution: {integrity: sha512-l7qMiNee7t82bH3SeyUCt9UF15EVmaBvsppY2zQtrbIhl/yzBTny+YUxsVjSjQ6gaqaeVtZmGocom8TzBlA4Yw==}
|
||||
engines: {node: '>=16.9.0'}
|
||||
|
||||
html-encoding-sniffer@6.0.0:
|
||||
resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==}
|
||||
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
|
||||
|
||||
http-errors@2.0.1:
|
||||
resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
http-proxy-agent@7.0.2:
|
||||
resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==}
|
||||
engines: {node: '>= 14'}
|
||||
|
||||
https-proxy-agent@7.0.6:
|
||||
resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==}
|
||||
engines: {node: '>= 14'}
|
||||
|
||||
iconv-lite@0.4.24:
|
||||
resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
|
@ -2799,6 +2892,9 @@ packages:
|
|||
resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==}
|
||||
engines: {node: '>=0.12.0'}
|
||||
|
||||
is-potential-custom-element-name@1.0.1:
|
||||
resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==}
|
||||
|
||||
is-promise@4.0.0:
|
||||
resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==}
|
||||
|
||||
|
|
@ -2869,6 +2965,15 @@ packages:
|
|||
resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==}
|
||||
hasBin: true
|
||||
|
||||
jsdom@27.4.0:
|
||||
resolution: {integrity: sha512-mjzqwWRD9Y1J1KUi7W97Gja1bwOOM5Ug0EZ6UDK3xS7j7mndrkwozHtSblfomlzyB4NepioNt+B2sOSzczVgtQ==}
|
||||
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
|
||||
peerDependencies:
|
||||
canvas: ^3.0.0
|
||||
peerDependenciesMeta:
|
||||
canvas:
|
||||
optional: true
|
||||
|
||||
jsesc@3.1.0:
|
||||
resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==}
|
||||
engines: {node: '>=6'}
|
||||
|
|
@ -3008,6 +3113,10 @@ packages:
|
|||
lop@0.4.2:
|
||||
resolution: {integrity: sha512-RefILVDQ4DKoRZsJ4Pj22TxE3omDO47yFpkIBoDKzkqPRISs5U1cnAdg/5583YPkWPaLIYHOKRMQSvjFsO26cw==}
|
||||
|
||||
lru-cache@11.2.5:
|
||||
resolution: {integrity: sha512-vFrFJkWtJvJnD5hg+hJvVE8Lh/TcMzKnTgCWmtBipwI5yLX/iX+5UB2tfuyODF5E7k9xEzMdYgGqaSb1c0c5Yw==}
|
||||
engines: {node: 20 || >=22}
|
||||
|
||||
lru-cache@5.1.1:
|
||||
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
|
||||
|
||||
|
|
@ -3028,6 +3137,9 @@ packages:
|
|||
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
mdn-data@2.12.2:
|
||||
resolution: {integrity: sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==}
|
||||
|
||||
media-typer@0.3.0:
|
||||
resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
|
@ -3224,6 +3336,9 @@ packages:
|
|||
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
parse5@8.0.0:
|
||||
resolution: {integrity: sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==}
|
||||
|
||||
parseurl@1.3.3:
|
||||
resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
|
@ -3543,6 +3658,10 @@ packages:
|
|||
resolution: {integrity: sha512-5LBh1Tls8c9xgGjw3QrMwETmTMVk0oFgvrFSvWx62llR2hcEInrKNZ2GZCCuuy2lvWrdl5jhbpeqc5hRYKFOcw==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
saxes@6.0.0:
|
||||
resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==}
|
||||
engines: {node: '>=v12.22.7'}
|
||||
|
||||
scheduler@0.27.0:
|
||||
resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==}
|
||||
|
||||
|
|
@ -3722,6 +3841,9 @@ packages:
|
|||
resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
symbol-tree@3.2.4:
|
||||
resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==}
|
||||
|
||||
tailwind-merge@2.6.0:
|
||||
resolution: {integrity: sha512-P+Vu1qXfzediirmHOC3xKGAYeZtPcV9g76X+xg2FD4tYgR71ewMA35Y3sCz3zhiN/dwefRpJX0yBcgwi1fXNQA==}
|
||||
|
||||
|
|
@ -3764,6 +3886,13 @@ packages:
|
|||
resolution: {integrity: sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
|
||||
tldts-core@7.0.19:
|
||||
resolution: {integrity: sha512-lJX2dEWx0SGH4O6p+7FPwYmJ/bu1JbcGJ8RLaG9b7liIgZ85itUVEPbMtWRVrde/0fnDPEPHW10ZsKW3kVsE9A==}
|
||||
|
||||
tldts@7.0.19:
|
||||
resolution: {integrity: sha512-8PWx8tvC4jDB39BQw1m4x8y5MH1BcQ5xHeL2n7UVFulMPH/3Q0uiamahFJ3lXA0zO2SUyRXuVVbWSDmstlt9YA==}
|
||||
hasBin: true
|
||||
|
||||
tmp@0.2.5:
|
||||
resolution: {integrity: sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==}
|
||||
engines: {node: '>=14.14'}
|
||||
|
|
@ -3780,6 +3909,14 @@ packages:
|
|||
resolution: {integrity: sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==}
|
||||
engines: {node: '>=14.16'}
|
||||
|
||||
tough-cookie@6.0.0:
|
||||
resolution: {integrity: sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==}
|
||||
engines: {node: '>=16'}
|
||||
|
||||
tr46@6.0.0:
|
||||
resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
traverse@0.3.9:
|
||||
resolution: {integrity: sha512-iawgk0hLP3SxGKDfnDJf8wTz4p2qImnyihM5Hh/sGvQ3K37dPi/w8sRhdNIxYA1TwFwc5mDhIJq+O0RsvXBKdQ==}
|
||||
|
||||
|
|
@ -3995,6 +4132,26 @@ packages:
|
|||
jsdom:
|
||||
optional: true
|
||||
|
||||
w3c-xmlserializer@5.0.0:
|
||||
resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
webidl-conversions@8.0.1:
|
||||
resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
whatwg-mimetype@4.0.0:
|
||||
resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
whatwg-mimetype@5.0.0:
|
||||
resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
whatwg-url@15.1.0:
|
||||
resolution: {integrity: sha512-2ytDk0kiEj/yu90JOAp44PVPUkO9+jVhyf+SybKlRHSDlvOOZhdPIrr7xTH64l4WixO2cP+wQIcgujkGBPPz6g==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
which-boxed-primitive@1.1.1:
|
||||
resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
|
@ -4044,6 +4201,10 @@ packages:
|
|||
utf-8-validate:
|
||||
optional: true
|
||||
|
||||
xml-name-validator@5.0.0:
|
||||
resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
xmlbuilder@10.1.1:
|
||||
resolution: {integrity: sha512-OyzrcFLL/nb6fMGHbiRDuPup9ljBycsdCypwuyg5AAHvyWzGfChJpCXMG88AGTIMFhGZ9RccFN1e6lhg3hkwKg==}
|
||||
engines: {node: '>=4.0'}
|
||||
|
|
@ -4094,8 +4255,28 @@ packages:
|
|||
|
||||
snapshots:
|
||||
|
||||
'@acemir/cssom@0.9.31': {}
|
||||
|
||||
'@alloc/quick-lru@5.2.0': {}
|
||||
|
||||
'@asamuzakjp/css-color@4.1.1':
|
||||
dependencies:
|
||||
'@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)
|
||||
'@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)
|
||||
'@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4)
|
||||
'@csstools/css-tokenizer': 3.0.4
|
||||
lru-cache: 11.2.5
|
||||
|
||||
'@asamuzakjp/dom-selector@6.7.6':
|
||||
dependencies:
|
||||
'@asamuzakjp/nwsapi': 2.3.9
|
||||
bidi-js: 1.0.3
|
||||
css-tree: 3.1.0
|
||||
is-potential-custom-element-name: 1.0.1
|
||||
lru-cache: 11.2.5
|
||||
|
||||
'@asamuzakjp/nwsapi@2.3.9': {}
|
||||
|
||||
'@babel/code-frame@7.28.6':
|
||||
dependencies:
|
||||
'@babel/helper-validator-identifier': 7.28.5
|
||||
|
|
@ -4210,6 +4391,28 @@ snapshots:
|
|||
|
||||
'@borewit/text-codec@0.2.1': {}
|
||||
|
||||
'@csstools/color-helpers@5.1.0': {}
|
||||
|
||||
'@csstools/css-calc@2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)':
|
||||
dependencies:
|
||||
'@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4)
|
||||
'@csstools/css-tokenizer': 3.0.4
|
||||
|
||||
'@csstools/css-color-parser@3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)':
|
||||
dependencies:
|
||||
'@csstools/color-helpers': 5.1.0
|
||||
'@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)
|
||||
'@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4)
|
||||
'@csstools/css-tokenizer': 3.0.4
|
||||
|
||||
'@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4)':
|
||||
dependencies:
|
||||
'@csstools/css-tokenizer': 3.0.4
|
||||
|
||||
'@csstools/css-syntax-patches-for-csstree@1.0.26': {}
|
||||
|
||||
'@csstools/css-tokenizer@3.0.4': {}
|
||||
|
||||
'@dnd-kit/accessibility@3.1.1(react@19.2.3)':
|
||||
dependencies:
|
||||
react: 19.2.3
|
||||
|
|
@ -4437,6 +4640,10 @@ snapshots:
|
|||
'@eslint/core': 0.17.0
|
||||
levn: 0.4.1
|
||||
|
||||
'@exodus/bytes@1.10.0(@noble/hashes@1.8.0)':
|
||||
optionalDependencies:
|
||||
'@noble/hashes': 1.8.0
|
||||
|
||||
'@fast-csv/format@4.3.5':
|
||||
dependencies:
|
||||
'@types/node': 14.18.63
|
||||
|
|
@ -5468,6 +5675,8 @@ snapshots:
|
|||
|
||||
acorn@8.15.0: {}
|
||||
|
||||
agent-base@7.1.4: {}
|
||||
|
||||
ajv-formats@3.0.1(ajv@8.17.1):
|
||||
optionalDependencies:
|
||||
ajv: 8.17.1
|
||||
|
|
@ -5642,6 +5851,10 @@ snapshots:
|
|||
node-addon-api: 8.5.0
|
||||
node-gyp-build: 4.8.4
|
||||
|
||||
bidi-js@1.0.3:
|
||||
dependencies:
|
||||
require-from-string: 2.0.2
|
||||
|
||||
big-integer@1.6.52: {}
|
||||
|
||||
binary-extensions@2.3.0: {}
|
||||
|
|
@ -5894,8 +6107,20 @@ snapshots:
|
|||
shebang-command: 2.0.0
|
||||
which: 2.0.2
|
||||
|
||||
css-tree@3.1.0:
|
||||
dependencies:
|
||||
mdn-data: 2.12.2
|
||||
source-map-js: 1.2.1
|
||||
|
||||
cssesc@3.0.0: {}
|
||||
|
||||
cssstyle@5.3.7:
|
||||
dependencies:
|
||||
'@asamuzakjp/css-color': 4.1.1
|
||||
'@csstools/css-syntax-patches-for-csstree': 1.0.26
|
||||
css-tree: 3.1.0
|
||||
lru-cache: 11.2.5
|
||||
|
||||
csstype@3.2.3: {}
|
||||
|
||||
d3-array@3.2.4:
|
||||
|
|
@ -5936,6 +6161,11 @@ snapshots:
|
|||
|
||||
d3-timer@3.0.1: {}
|
||||
|
||||
data-urls@6.0.1:
|
||||
dependencies:
|
||||
whatwg-mimetype: 5.0.0
|
||||
whatwg-url: 15.1.0
|
||||
|
||||
data-view-buffer@1.0.2:
|
||||
dependencies:
|
||||
call-bound: 1.0.4
|
||||
|
|
@ -5966,6 +6196,8 @@ snapshots:
|
|||
|
||||
decimal.js-light@2.5.1: {}
|
||||
|
||||
decimal.js@10.6.0: {}
|
||||
|
||||
deep-is@0.1.4: {}
|
||||
|
||||
define-data-property@1.1.4:
|
||||
|
|
@ -6039,6 +6271,8 @@ snapshots:
|
|||
dependencies:
|
||||
once: 1.4.0
|
||||
|
||||
entities@6.0.1: {}
|
||||
|
||||
es-abstract@1.24.1:
|
||||
dependencies:
|
||||
array-buffer-byte-length: 1.0.2
|
||||
|
|
@ -6655,6 +6889,12 @@ snapshots:
|
|||
|
||||
hono@4.11.7: {}
|
||||
|
||||
html-encoding-sniffer@6.0.0(@noble/hashes@1.8.0):
|
||||
dependencies:
|
||||
'@exodus/bytes': 1.10.0(@noble/hashes@1.8.0)
|
||||
transitivePeerDependencies:
|
||||
- '@noble/hashes'
|
||||
|
||||
http-errors@2.0.1:
|
||||
dependencies:
|
||||
depd: 2.0.0
|
||||
|
|
@ -6663,6 +6903,20 @@ snapshots:
|
|||
statuses: 2.0.2
|
||||
toidentifier: 1.0.1
|
||||
|
||||
http-proxy-agent@7.0.2:
|
||||
dependencies:
|
||||
agent-base: 7.1.4
|
||||
debug: 4.4.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
https-proxy-agent@7.0.6:
|
||||
dependencies:
|
||||
agent-base: 7.1.4
|
||||
debug: 4.4.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
iconv-lite@0.4.24:
|
||||
dependencies:
|
||||
safer-buffer: 2.1.2
|
||||
|
|
@ -6784,6 +7038,8 @@ snapshots:
|
|||
|
||||
is-number@7.0.0: {}
|
||||
|
||||
is-potential-custom-element-name@1.0.1: {}
|
||||
|
||||
is-promise@4.0.0: {}
|
||||
|
||||
is-regex@1.2.1:
|
||||
|
|
@ -6855,6 +7111,34 @@ snapshots:
|
|||
dependencies:
|
||||
argparse: 2.0.1
|
||||
|
||||
jsdom@27.4.0(@noble/hashes@1.8.0):
|
||||
dependencies:
|
||||
'@acemir/cssom': 0.9.31
|
||||
'@asamuzakjp/dom-selector': 6.7.6
|
||||
'@exodus/bytes': 1.10.0(@noble/hashes@1.8.0)
|
||||
cssstyle: 5.3.7
|
||||
data-urls: 6.0.1
|
||||
decimal.js: 10.6.0
|
||||
html-encoding-sniffer: 6.0.0(@noble/hashes@1.8.0)
|
||||
http-proxy-agent: 7.0.2
|
||||
https-proxy-agent: 7.0.6
|
||||
is-potential-custom-element-name: 1.0.1
|
||||
parse5: 8.0.0
|
||||
saxes: 6.0.0
|
||||
symbol-tree: 3.2.4
|
||||
tough-cookie: 6.0.0
|
||||
w3c-xmlserializer: 5.0.0
|
||||
webidl-conversions: 8.0.1
|
||||
whatwg-mimetype: 4.0.0
|
||||
whatwg-url: 15.1.0
|
||||
ws: 8.19.0
|
||||
xml-name-validator: 5.0.0
|
||||
transitivePeerDependencies:
|
||||
- '@noble/hashes'
|
||||
- bufferutil
|
||||
- supports-color
|
||||
- utf-8-validate
|
||||
|
||||
jsesc@3.1.0: {}
|
||||
|
||||
json-buffer@3.0.1: {}
|
||||
|
|
@ -6984,6 +7268,8 @@ snapshots:
|
|||
option: 0.2.4
|
||||
underscore: 1.13.7
|
||||
|
||||
lru-cache@11.2.5: {}
|
||||
|
||||
lru-cache@5.1.1:
|
||||
dependencies:
|
||||
yallist: 3.1.1
|
||||
|
|
@ -7011,6 +7297,8 @@ snapshots:
|
|||
|
||||
math-intrinsics@1.1.0: {}
|
||||
|
||||
mdn-data@2.12.2: {}
|
||||
|
||||
media-typer@0.3.0: {}
|
||||
|
||||
media-typer@1.1.0: {}
|
||||
|
|
@ -7183,6 +7471,10 @@ snapshots:
|
|||
dependencies:
|
||||
callsites: 3.1.0
|
||||
|
||||
parse5@8.0.0:
|
||||
dependencies:
|
||||
entities: 6.0.1
|
||||
|
||||
parseurl@1.3.3: {}
|
||||
|
||||
path-exists@4.0.0: {}
|
||||
|
|
@ -7519,6 +7811,10 @@ snapshots:
|
|||
dependencies:
|
||||
xmlchars: 2.2.0
|
||||
|
||||
saxes@6.0.0:
|
||||
dependencies:
|
||||
xmlchars: 2.2.0
|
||||
|
||||
scheduler@0.27.0: {}
|
||||
|
||||
section-matter@1.0.0:
|
||||
|
|
@ -7783,6 +8079,8 @@ snapshots:
|
|||
|
||||
supports-preserve-symlinks-flag@1.0.0: {}
|
||||
|
||||
symbol-tree@3.2.4: {}
|
||||
|
||||
tailwind-merge@2.6.0: {}
|
||||
|
||||
tailwindcss-animate@1.0.7(tailwindcss@3.4.19(tsx@4.21.0)):
|
||||
|
|
@ -7846,6 +8144,12 @@ snapshots:
|
|||
|
||||
tinyrainbow@3.0.3: {}
|
||||
|
||||
tldts-core@7.0.19: {}
|
||||
|
||||
tldts@7.0.19:
|
||||
dependencies:
|
||||
tldts-core: 7.0.19
|
||||
|
||||
tmp@0.2.5: {}
|
||||
|
||||
to-regex-range@5.0.1:
|
||||
|
|
@ -7860,6 +8164,14 @@ snapshots:
|
|||
'@tokenizer/token': 0.3.0
|
||||
ieee754: 1.2.1
|
||||
|
||||
tough-cookie@6.0.0:
|
||||
dependencies:
|
||||
tldts: 7.0.19
|
||||
|
||||
tr46@6.0.0:
|
||||
dependencies:
|
||||
punycode: 2.3.1
|
||||
|
||||
traverse@0.3.9: {}
|
||||
|
||||
tree-kill@1.2.2: {}
|
||||
|
|
@ -8029,7 +8341,7 @@ snapshots:
|
|||
jiti: 1.21.7
|
||||
tsx: 4.21.0
|
||||
|
||||
vitest@4.0.18(@types/node@22.19.7)(jiti@1.21.7)(tsx@4.21.0):
|
||||
vitest@4.0.18(@types/node@22.19.7)(jiti@1.21.7)(jsdom@27.4.0(@noble/hashes@1.8.0))(tsx@4.21.0):
|
||||
dependencies:
|
||||
'@vitest/expect': 4.0.18
|
||||
'@vitest/mocker': 4.0.18(vite@6.4.1(@types/node@22.19.7)(jiti@1.21.7)(tsx@4.21.0))
|
||||
|
|
@ -8053,6 +8365,7 @@ snapshots:
|
|||
why-is-node-running: 2.3.0
|
||||
optionalDependencies:
|
||||
'@types/node': 22.19.7
|
||||
jsdom: 27.4.0(@noble/hashes@1.8.0)
|
||||
transitivePeerDependencies:
|
||||
- jiti
|
||||
- less
|
||||
|
|
@ -8066,6 +8379,21 @@ snapshots:
|
|||
- tsx
|
||||
- yaml
|
||||
|
||||
w3c-xmlserializer@5.0.0:
|
||||
dependencies:
|
||||
xml-name-validator: 5.0.0
|
||||
|
||||
webidl-conversions@8.0.1: {}
|
||||
|
||||
whatwg-mimetype@4.0.0: {}
|
||||
|
||||
whatwg-mimetype@5.0.0: {}
|
||||
|
||||
whatwg-url@15.1.0:
|
||||
dependencies:
|
||||
tr46: 6.0.0
|
||||
webidl-conversions: 8.0.1
|
||||
|
||||
which-boxed-primitive@1.1.1:
|
||||
dependencies:
|
||||
is-bigint: 1.1.0
|
||||
|
|
@ -8128,6 +8456,8 @@ snapshots:
|
|||
|
||||
ws@8.19.0: {}
|
||||
|
||||
xml-name-validator@5.0.0: {}
|
||||
|
||||
xmlbuilder@10.1.1: {}
|
||||
|
||||
xmlchars@2.2.0: {}
|
||||
|
|
|
|||
|
|
@ -63,7 +63,7 @@ describe('AttachmentService', () => {
|
|||
|
||||
it('should sanitize filenames with special characters', async () => {
|
||||
const mockFile = {
|
||||
originalname: '../../../etc/passwd',
|
||||
originalname: '../../../etc/passwd.txt',
|
||||
mimetype: 'text/plain',
|
||||
size: 100,
|
||||
buffer: Buffer.from('Test'),
|
||||
|
|
@ -98,7 +98,7 @@ describe('AttachmentService', () => {
|
|||
} as Express.Multer.File;
|
||||
|
||||
await expect(service.saveAttachment(testTaskId, mockFile)).rejects.toThrow(
|
||||
/File type.*is not allowed/
|
||||
/not allowed/
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -40,20 +40,21 @@ router.post('/:id/attachments', upload.array('files', 20), async (req: Request,
|
|||
|
||||
const currentAttachments = task.attachments || [];
|
||||
const newAttachments: Attachment[] = [];
|
||||
const rejectedFiles: { filename: string; error: string }[] = [];
|
||||
|
||||
// Process each file
|
||||
for (const file of files) {
|
||||
try {
|
||||
// Save attachment
|
||||
// Save attachment (includes magic-byte MIME validation)
|
||||
const attachment = await attachmentService.saveAttachment(
|
||||
taskId,
|
||||
file,
|
||||
[...currentAttachments, ...newAttachments]
|
||||
);
|
||||
|
||||
// Extract text
|
||||
// Extract text using the validated MIME type
|
||||
const filepath = attachmentService.getAttachmentPath(taskId, attachment.filename);
|
||||
const extractedText = await textExtractionService.extractText(filepath, file.mimetype);
|
||||
const extractedText = await textExtractionService.extractText(filepath, attachment.mimeType);
|
||||
|
||||
// Save extracted text if available
|
||||
if (extractedText) {
|
||||
|
|
@ -62,11 +63,21 @@ router.post('/:id/attachments', upload.array('files', 20), async (req: Request,
|
|||
|
||||
newAttachments.push(attachment);
|
||||
} catch (error) {
|
||||
console.error('Error processing file:', error);
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
console.error(`Rejected file "${file.originalname}":`, message);
|
||||
rejectedFiles.push({ filename: file.originalname, error: message });
|
||||
// Continue with other files
|
||||
}
|
||||
}
|
||||
|
||||
// If ALL files were rejected, return 400
|
||||
if (newAttachments.length === 0 && rejectedFiles.length > 0) {
|
||||
return res.status(400).json({
|
||||
error: 'All files were rejected',
|
||||
rejected: rejectedFiles,
|
||||
});
|
||||
}
|
||||
|
||||
// Update task with new attachments
|
||||
const updatedTask = await taskService.updateTask(taskId, {
|
||||
attachments: [...currentAttachments, ...newAttachments],
|
||||
|
|
@ -76,6 +87,8 @@ router.post('/:id/attachments', upload.array('files', 20), async (req: Request,
|
|||
success: true,
|
||||
attachments: newAttachments,
|
||||
task: updatedTask,
|
||||
// Include rejected files info if some were rejected
|
||||
...(rejectedFiles.length > 0 && { rejected: rejectedFiles }),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Upload error:', error);
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { nanoid } from 'nanoid';
|
|||
import mime from 'mime-types';
|
||||
import type { Attachment, AttachmentLimits } from '@veritas-kanban/shared';
|
||||
import { DEFAULT_ATTACHMENT_LIMITS, ALLOWED_MIME_TYPES } from '@veritas-kanban/shared';
|
||||
import { validateMimeType, getAllowedTypesDescription } from './mime-validation.js';
|
||||
|
||||
// Default paths - resolve to project root (one level up from server/)
|
||||
const DEFAULT_PROJECT_ROOT = path.resolve(process.cwd(), '..');
|
||||
|
|
@ -98,10 +99,11 @@ export class AttachmentService {
|
|||
}
|
||||
|
||||
/**
|
||||
* Validate file against limits and allowed types
|
||||
* Validate file against limits and allowed types (basic checks only).
|
||||
* For full MIME validation including magic bytes, use validateFileWithMime().
|
||||
*/
|
||||
validateFile(file: Express.Multer.File, currentAttachments: Attachment[] = []): void {
|
||||
// Check file size
|
||||
// Check file size (global limit)
|
||||
if (file.size > this.limits.maxFileSize) {
|
||||
throw new Error(
|
||||
`File size (${Math.round(file.size / 1024 / 1024)}MB) exceeds maximum allowed size (${Math.round(this.limits.maxFileSize / 1024 / 1024)}MB)`
|
||||
|
|
@ -123,22 +125,54 @@ export class AttachmentService {
|
|||
);
|
||||
}
|
||||
|
||||
// Check MIME type
|
||||
// Check MIME type against shared allowed list (quick pre-check)
|
||||
if (!ALLOWED_MIME_TYPES.includes(file.mimetype)) {
|
||||
throw new Error(`File type "${file.mimetype}" is not allowed`);
|
||||
throw new Error(
|
||||
`File type "${file.mimetype}" is not allowed. Allowed types: ${getAllowedTypesDescription()}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save an uploaded file and return attachment metadata
|
||||
* Validate file with full server-side MIME type detection using magic bytes.
|
||||
* This verifies the actual file content matches the claimed type to prevent
|
||||
* disguised file uploads (e.g., executables renamed as .jpg).
|
||||
*
|
||||
* Returns the validated/detected MIME type to use for the attachment metadata.
|
||||
*/
|
||||
async validateFileWithMime(
|
||||
file: Express.Multer.File,
|
||||
currentAttachments: Attachment[] = []
|
||||
): Promise<string> {
|
||||
// Run basic validation first (size, count, total limits)
|
||||
this.validateFile(file, currentAttachments);
|
||||
|
||||
// Run magic-byte MIME validation
|
||||
const result = await validateMimeType(
|
||||
file.buffer,
|
||||
file.originalname,
|
||||
file.mimetype,
|
||||
file.size,
|
||||
);
|
||||
|
||||
if (!result.valid) {
|
||||
throw new Error(result.error || 'File type validation failed');
|
||||
}
|
||||
|
||||
return result.effectiveMime;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save an uploaded file and return attachment metadata.
|
||||
* Performs full MIME validation using magic bytes before saving.
|
||||
*/
|
||||
async saveAttachment(
|
||||
taskId: string,
|
||||
file: Express.Multer.File,
|
||||
currentAttachments: Attachment[] = []
|
||||
): Promise<Attachment> {
|
||||
// Validate file
|
||||
this.validateFile(file, currentAttachments);
|
||||
// Validate file with magic-byte MIME detection
|
||||
const validatedMime = await this.validateFileWithMime(file, currentAttachments);
|
||||
|
||||
// Generate attachment ID and sanitize filename
|
||||
const attachmentId = `att_${Date.now()}_${nanoid(6)}`;
|
||||
|
|
@ -153,12 +187,12 @@ export class AttachmentService {
|
|||
const filepath = path.join(taskDir, filename);
|
||||
await fs.writeFile(filepath, file.buffer);
|
||||
|
||||
// Create attachment metadata
|
||||
// Create attachment metadata (use validated MIME type, not client-provided)
|
||||
const attachment: Attachment = {
|
||||
id: attachmentId,
|
||||
filename,
|
||||
originalName: file.originalname,
|
||||
mimeType: file.mimetype,
|
||||
mimeType: validatedMime,
|
||||
size: file.size,
|
||||
uploaded: new Date().toISOString(),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,9 +1,16 @@
|
|||
import fs from 'fs/promises';
|
||||
import { watch, type FSWatcher } from 'fs';
|
||||
import path from 'path';
|
||||
import { simpleGit } from 'simple-git';
|
||||
import type { AppConfig, RepoConfig, AgentConfig, AgentType, FeatureSettings } from '@veritas-kanban/shared';
|
||||
import { DEFAULT_FEATURE_SETTINGS } from '@veritas-kanban/shared';
|
||||
|
||||
/** How long cached config stays valid before re-reading from disk */
|
||||
const CACHE_TTL_MS = 60_000; // 60 seconds
|
||||
|
||||
/** Ignore file-watcher events within this window after our own writes */
|
||||
const WRITE_DEBOUNCE_MS = 200;
|
||||
|
||||
// Default paths - resolve to project root
|
||||
const PROJECT_ROOT = path.resolve(process.cwd(), '..');
|
||||
const CONFIG_DIR = path.join(PROJECT_ROOT, '.veritas-kanban');
|
||||
|
|
|
|||
372
server/src/services/mime-validation.ts
Normal file
372
server/src/services/mime-validation.ts
Normal file
|
|
@ -0,0 +1,372 @@
|
|||
/**
|
||||
* Server-side MIME type validation using magic bytes.
|
||||
*
|
||||
* This module validates uploaded files by inspecting their actual content
|
||||
* (magic bytes) rather than trusting the client-provided MIME type or
|
||||
* file extension. This prevents attackers from uploading executables or
|
||||
* scripts disguised as innocent file types.
|
||||
*/
|
||||
|
||||
import { fileTypeFromBuffer } from 'file-type';
|
||||
import path from 'path';
|
||||
|
||||
// ─── Allowed MIME types with metadata ────────────────────────────────────────
|
||||
// Each entry maps a MIME type to its allowed extensions and per-type size limit.
|
||||
// Files not in this map are rejected outright.
|
||||
|
||||
export interface AllowedTypeInfo {
|
||||
/** Human-readable category for error messages */
|
||||
category: string;
|
||||
/** Allowed file extensions (lowercase, without dot) */
|
||||
extensions: string[];
|
||||
/** Max file size in bytes for this specific type */
|
||||
maxSize: number;
|
||||
}
|
||||
|
||||
const MB = 1024 * 1024;
|
||||
|
||||
/**
|
||||
* Whitelist of allowed MIME types with per-type size limits.
|
||||
*
|
||||
* Categories:
|
||||
* - Images: jpeg, png, gif, webp, svg (SVG is text-based, no magic bytes)
|
||||
* - Documents: PDF, plain text, markdown, CSV, HTML
|
||||
* - Office: docx, xlsx, pptx, legacy doc/xls
|
||||
* - Data/Config: JSON, XML, YAML
|
||||
*/
|
||||
export const ALLOWED_TYPES: Record<string, AllowedTypeInfo> = {
|
||||
// ── Images ──────────────────────────────────────────────────────────────────
|
||||
'image/jpeg': { category: 'image', extensions: ['jpg', 'jpeg'], maxSize: 10 * MB },
|
||||
'image/png': { category: 'image', extensions: ['png'], maxSize: 10 * MB },
|
||||
'image/gif': { category: 'image', extensions: ['gif'], maxSize: 5 * MB },
|
||||
'image/webp': { category: 'image', extensions: ['webp'], maxSize: 10 * MB },
|
||||
'image/svg+xml': { category: 'image', extensions: ['svg'], maxSize: 1 * MB },
|
||||
|
||||
// ── Documents ───────────────────────────────────────────────────────────────
|
||||
'application/pdf': { category: 'document', extensions: ['pdf'], maxSize: 10 * MB },
|
||||
'text/plain': { category: 'text', extensions: ['txt', 'log', 'text'], maxSize: 5 * MB },
|
||||
'text/markdown': { category: 'text', extensions: ['md', 'markdown'], maxSize: 5 * MB },
|
||||
'text/html': { category: 'text', extensions: ['html', 'htm'], maxSize: 5 * MB },
|
||||
'text/csv': { category: 'text', extensions: ['csv'], maxSize: 10 * MB },
|
||||
|
||||
// ── Office ──────────────────────────────────────────────────────────────────
|
||||
'application/msword': {
|
||||
category: 'office', extensions: ['doc'], maxSize: 10 * MB,
|
||||
},
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': {
|
||||
category: 'office', extensions: ['docx'], maxSize: 10 * MB,
|
||||
},
|
||||
'application/vnd.ms-excel': {
|
||||
category: 'office', extensions: ['xls'], maxSize: 10 * MB,
|
||||
},
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': {
|
||||
category: 'office', extensions: ['xlsx'], maxSize: 10 * MB,
|
||||
},
|
||||
'application/vnd.ms-powerpoint': {
|
||||
category: 'office', extensions: ['ppt'], maxSize: 10 * MB,
|
||||
},
|
||||
'application/vnd.openxmlformats-officedocument.presentationml.presentation': {
|
||||
category: 'office', extensions: ['pptx'], maxSize: 10 * MB,
|
||||
},
|
||||
|
||||
// ── Data / Config ──────────────────────────────────────────────────────────
|
||||
'application/json': { category: 'data', extensions: ['json'], maxSize: 5 * MB },
|
||||
'application/xml': { category: 'data', extensions: ['xml'], maxSize: 5 * MB },
|
||||
'text/xml': { category: 'data', extensions: ['xml'], maxSize: 5 * MB },
|
||||
'application/yaml': { category: 'data', extensions: ['yaml', 'yml'], maxSize: 5 * MB },
|
||||
'text/yaml': { category: 'data', extensions: ['yaml', 'yml'], maxSize: 5 * MB },
|
||||
};
|
||||
|
||||
// ─── Dangerous MIME types (always rejected) ──────────────────────────────────
|
||||
// Even if someone manages to craft a file that looks like these, block them.
|
||||
|
||||
const BLOCKED_MIME_TYPES = new Set([
|
||||
'application/x-executable',
|
||||
'application/x-msdos-program',
|
||||
'application/x-msdownload',
|
||||
'application/x-elf',
|
||||
'application/x-dosexec',
|
||||
'application/x-mach-binary',
|
||||
'application/vnd.microsoft.portable-executable',
|
||||
'application/x-sharedlib',
|
||||
'application/x-shellscript',
|
||||
'application/x-sh',
|
||||
'application/x-csh',
|
||||
'application/x-bat',
|
||||
'application/x-msi',
|
||||
'application/java-archive',
|
||||
'application/x-java-applet',
|
||||
'application/javascript',
|
||||
'text/javascript',
|
||||
'application/x-httpd-php',
|
||||
'application/x-python-code',
|
||||
'application/x-perl',
|
||||
'application/x-ruby',
|
||||
'application/wasm',
|
||||
]);
|
||||
|
||||
// ─── Extension-to-MIME mapping for text-based files ──────────────────────────
|
||||
// file-type cannot detect these from magic bytes (they're just text).
|
||||
// We allow them based on extension alone, but only if the claimed MIME
|
||||
// is also in the allowed list.
|
||||
|
||||
const TEXT_BASED_EXTENSIONS = new Set([
|
||||
'txt', 'log', 'text', 'md', 'markdown', 'csv',
|
||||
'html', 'htm', 'json', 'xml', 'yaml', 'yml', 'svg',
|
||||
]);
|
||||
|
||||
// ─── Extension → expected MIME types ─────────────────────────────────────────
|
||||
|
||||
function buildExtensionToMimeMap(): Map<string, string[]> {
|
||||
const map = new Map<string, string[]>();
|
||||
for (const [mime, info] of Object.entries(ALLOWED_TYPES)) {
|
||||
for (const ext of info.extensions) {
|
||||
const existing = map.get(ext) || [];
|
||||
existing.push(mime);
|
||||
map.set(ext, existing);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
const EXTENSION_TO_MIMES = buildExtensionToMimeMap();
|
||||
|
||||
// ─── Dangerous file extensions ───────────────────────────────────────────────
|
||||
const BLOCKED_EXTENSIONS = new Set([
|
||||
'exe', 'com', 'bat', 'cmd', 'msi', 'scr', 'pif', 'vbs', 'vbe',
|
||||
'js', 'jse', 'ws', 'wsf', 'wsc', 'wsh', 'ps1', 'ps2', 'psc1',
|
||||
'psc2', 'msh', 'msh1', 'msh2', 'inf', 'reg', 'rgs', 'sct',
|
||||
'shb', 'shs', 'lnk', 'dll', 'sys', 'drv', 'ocx', 'cpl',
|
||||
'hta', 'jar', 'class', 'php', 'py', 'pyc', 'pyo', 'rb',
|
||||
'pl', 'sh', 'bash', 'csh', 'ksh', 'wasm', 'elf', 'bin',
|
||||
'app', 'action', 'command', 'workflow', 'dmg', 'iso',
|
||||
]);
|
||||
|
||||
// ─── Public validation types ─────────────────────────────────────────────────
|
||||
|
||||
export interface MimeValidationResult {
|
||||
valid: boolean;
|
||||
/** Detected MIME type from magic bytes (null for text-based files) */
|
||||
detectedMime: string | null;
|
||||
/** The MIME type to use (detected or claimed) */
|
||||
effectiveMime: string;
|
||||
/** Error message if invalid */
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// ─── Main validation function ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Validate a file's MIME type by inspecting its magic bytes.
|
||||
*
|
||||
* Checks performed (in order):
|
||||
* 1. Extension is not on the blocked list
|
||||
* 2. Extension is recognized (maps to an allowed MIME type)
|
||||
* 3. Magic bytes are inspected to detect actual file type
|
||||
* 4. Detected type is not on the blocked list
|
||||
* 5. Detected type matches the claimed MIME / extension
|
||||
* 6. Per-type file size limit is enforced
|
||||
*
|
||||
* For text-based files (no magic bytes), we validate that the claimed
|
||||
* MIME type is allowed and matches the extension.
|
||||
*/
|
||||
export async function validateMimeType(
|
||||
buffer: Buffer,
|
||||
originalName: string,
|
||||
claimedMime: string,
|
||||
fileSize: number,
|
||||
): Promise<MimeValidationResult> {
|
||||
const ext = path.extname(originalName).toLowerCase().replace('.', '');
|
||||
|
||||
// 1. Block dangerous extensions
|
||||
if (BLOCKED_EXTENSIONS.has(ext)) {
|
||||
return {
|
||||
valid: false,
|
||||
detectedMime: null,
|
||||
effectiveMime: claimedMime,
|
||||
error: `File extension ".${ext}" is not allowed. Executable and script files are blocked for security.`,
|
||||
};
|
||||
}
|
||||
|
||||
// 2. Check extension is recognized
|
||||
const allowedMimesForExt = EXTENSION_TO_MIMES.get(ext);
|
||||
if (!allowedMimesForExt) {
|
||||
return {
|
||||
valid: false,
|
||||
detectedMime: null,
|
||||
effectiveMime: claimedMime,
|
||||
error: `File extension ".${ext}" is not recognized. Allowed types: images (jpg, png, gif, webp, svg), documents (pdf, txt, md, csv, html), office files (doc/x, xls/x, ppt/x), and data formats (json, xml, yaml).`,
|
||||
};
|
||||
}
|
||||
|
||||
// 3. Detect actual MIME type from magic bytes
|
||||
const detected = await fileTypeFromBuffer(buffer);
|
||||
|
||||
if (detected) {
|
||||
// 4. Block dangerous detected types
|
||||
if (BLOCKED_MIME_TYPES.has(detected.mime)) {
|
||||
return {
|
||||
valid: false,
|
||||
detectedMime: detected.mime,
|
||||
effectiveMime: detected.mime,
|
||||
error: `File content detected as "${detected.mime}" which is blocked for security. The file may be disguised as a ".${ext}" file.`,
|
||||
};
|
||||
}
|
||||
|
||||
// 5a. Verify detected type is in our allowed list
|
||||
const typeInfo = ALLOWED_TYPES[detected.mime];
|
||||
if (!typeInfo) {
|
||||
// Special case: Office Open XML formats are zip-based.
|
||||
// file-type detects them as 'application/zip' sometimes.
|
||||
// If the extension maps to an allowed office type, allow it.
|
||||
if (detected.mime === 'application/zip' && allowedMimesForExt.some(m => ALLOWED_TYPES[m]?.category === 'office')) {
|
||||
// Office files are zip-based, this is expected
|
||||
const effectiveMime = allowedMimesForExt[0];
|
||||
const officeTypeInfo = ALLOWED_TYPES[effectiveMime];
|
||||
if (officeTypeInfo && fileSize > officeTypeInfo.maxSize) {
|
||||
return {
|
||||
valid: false,
|
||||
detectedMime: detected.mime,
|
||||
effectiveMime,
|
||||
error: `File size (${formatSize(fileSize)}) exceeds the ${formatSize(officeTypeInfo.maxSize)} limit for ${officeTypeInfo.category} files.`,
|
||||
};
|
||||
}
|
||||
return { valid: true, detectedMime: detected.mime, effectiveMime };
|
||||
}
|
||||
|
||||
// Also handle CFB (Compound File Binary) for legacy Office formats
|
||||
if (detected.mime === 'application/x-cfb' && allowedMimesForExt.some(m =>
|
||||
m === 'application/msword' ||
|
||||
m === 'application/vnd.ms-excel' ||
|
||||
m === 'application/vnd.ms-powerpoint'
|
||||
)) {
|
||||
const effectiveMime = allowedMimesForExt[0];
|
||||
const legacyTypeInfo = ALLOWED_TYPES[effectiveMime];
|
||||
if (legacyTypeInfo && fileSize > legacyTypeInfo.maxSize) {
|
||||
return {
|
||||
valid: false,
|
||||
detectedMime: detected.mime,
|
||||
effectiveMime,
|
||||
error: `File size (${formatSize(fileSize)}) exceeds the ${formatSize(legacyTypeInfo.maxSize)} limit for ${legacyTypeInfo.category} files.`,
|
||||
};
|
||||
}
|
||||
return { valid: true, detectedMime: detected.mime, effectiveMime };
|
||||
}
|
||||
|
||||
return {
|
||||
valid: false,
|
||||
detectedMime: detected.mime,
|
||||
effectiveMime: detected.mime,
|
||||
error: `File content detected as "${detected.mime}" which is not an allowed type. The file extension is ".${ext}" but the actual content doesn't match any allowed format.`,
|
||||
};
|
||||
}
|
||||
|
||||
// 5b. Verify extension matches detected type
|
||||
if (!typeInfo.extensions.includes(ext)) {
|
||||
// Allow some flexibility: e.g., jpg/jpeg are interchangeable
|
||||
// Check if the extension maps to the same category
|
||||
const extCategory = allowedMimesForExt
|
||||
.map(m => ALLOWED_TYPES[m]?.category)
|
||||
.filter(Boolean);
|
||||
|
||||
if (!extCategory.includes(typeInfo.category)) {
|
||||
return {
|
||||
valid: false,
|
||||
detectedMime: detected.mime,
|
||||
effectiveMime: detected.mime,
|
||||
error: `File extension ".${ext}" doesn't match file content (detected as ${typeInfo.category}: ${detected.mime}). This may indicate a disguised file.`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Per-type size limit
|
||||
if (fileSize > typeInfo.maxSize) {
|
||||
return {
|
||||
valid: false,
|
||||
detectedMime: detected.mime,
|
||||
effectiveMime: detected.mime,
|
||||
error: `File size (${formatSize(fileSize)}) exceeds the ${formatSize(typeInfo.maxSize)} limit for ${typeInfo.category} files.`,
|
||||
};
|
||||
}
|
||||
|
||||
return { valid: true, detectedMime: detected.mime, effectiveMime: detected.mime };
|
||||
}
|
||||
|
||||
// ── No magic bytes detected (text-based files) ────────────────────────────
|
||||
if (TEXT_BASED_EXTENSIONS.has(ext)) {
|
||||
// Verify the claimed MIME type is in the allowed list
|
||||
const typeInfo = ALLOWED_TYPES[claimedMime];
|
||||
if (!typeInfo) {
|
||||
// Try to find the correct MIME type from extension mapping
|
||||
const expectedMime = allowedMimesForExt[0];
|
||||
const expectedInfo = ALLOWED_TYPES[expectedMime];
|
||||
if (!expectedInfo) {
|
||||
return {
|
||||
valid: false,
|
||||
detectedMime: null,
|
||||
effectiveMime: claimedMime,
|
||||
error: `File type "${claimedMime}" is not allowed.`,
|
||||
};
|
||||
}
|
||||
|
||||
// Use the extension-based MIME type instead of the claimed one
|
||||
if (fileSize > expectedInfo.maxSize) {
|
||||
return {
|
||||
valid: false,
|
||||
detectedMime: null,
|
||||
effectiveMime: expectedMime,
|
||||
error: `File size (${formatSize(fileSize)}) exceeds the ${formatSize(expectedInfo.maxSize)} limit for ${expectedInfo.category} files.`,
|
||||
};
|
||||
}
|
||||
return { valid: true, detectedMime: null, effectiveMime: expectedMime };
|
||||
}
|
||||
|
||||
// Check per-type size limit
|
||||
if (fileSize > typeInfo.maxSize) {
|
||||
return {
|
||||
valid: false,
|
||||
detectedMime: null,
|
||||
effectiveMime: claimedMime,
|
||||
error: `File size (${formatSize(fileSize)}) exceeds the ${formatSize(typeInfo.maxSize)} limit for ${typeInfo.category} files.`,
|
||||
};
|
||||
}
|
||||
|
||||
return { valid: true, detectedMime: null, effectiveMime: claimedMime };
|
||||
}
|
||||
|
||||
// No magic bytes and not a recognized text-based extension
|
||||
return {
|
||||
valid: false,
|
||||
detectedMime: null,
|
||||
effectiveMime: claimedMime,
|
||||
error: `Could not verify file type for ".${ext}". Only known file types with verifiable content are allowed.`,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes}B`;
|
||||
if (bytes < MB) return `${Math.round(bytes / 1024)}KB`;
|
||||
return `${Math.round(bytes / MB)}MB`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a human-readable list of allowed file types for documentation/error messages.
|
||||
*/
|
||||
export function getAllowedTypesDescription(): string {
|
||||
const categories = new Map<string, string[]>();
|
||||
for (const [, info] of Object.entries(ALLOWED_TYPES)) {
|
||||
const exts = categories.get(info.category) || [];
|
||||
for (const ext of info.extensions) {
|
||||
if (!exts.includes(ext)) exts.push(ext);
|
||||
}
|
||||
categories.set(info.category, exts);
|
||||
}
|
||||
|
||||
const parts: string[] = [];
|
||||
for (const [category, exts] of categories) {
|
||||
parts.push(`${category}: .${exts.join(', .')}`);
|
||||
}
|
||||
return parts.join('; ');
|
||||
}
|
||||
|
|
@ -89,6 +89,8 @@ export const ALLOWED_MIME_TYPES = [
|
|||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'application/vnd.ms-excel',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'application/vnd.ms-powerpoint',
|
||||
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||
'text/plain',
|
||||
'text/markdown',
|
||||
'text/html',
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@
|
|||
"@vitejs/plugin-react": "^4.3.0",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"eslint": "^9.17.0",
|
||||
"jsdom": "^27.4.0",
|
||||
"postcss": "^8.4.49",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "^5.7.0",
|
||||
|
|
|
|||
|
|
@ -6,29 +6,32 @@ import { KeyboardShortcutsDialog } from './components/layout/KeyboardShortcutsDi
|
|||
import { BulkActionsProvider } from './hooks/useBulkActions';
|
||||
import { useTaskSync } from './hooks/useTaskSync';
|
||||
import { TaskConfigProvider } from './contexts/TaskConfigContext';
|
||||
import { WebSocketStatusProvider } from './contexts/WebSocketContext';
|
||||
import { AuthProvider } from './hooks/useAuth';
|
||||
import { AuthGuard } from './components/auth';
|
||||
|
||||
// Main app content (only rendered when authenticated)
|
||||
function AppContent() {
|
||||
// Connect to WebSocket for real-time task updates
|
||||
useTaskSync();
|
||||
const { isConnected } = useTaskSync();
|
||||
|
||||
return (
|
||||
<KeyboardProvider>
|
||||
<BulkActionsProvider>
|
||||
<TaskConfigProvider>
|
||||
<div className="min-h-screen bg-background">
|
||||
<Header />
|
||||
<main className="container mx-auto px-4 py-6">
|
||||
<KanbanBoard />
|
||||
</main>
|
||||
<Toaster />
|
||||
<KeyboardShortcutsDialog />
|
||||
</div>
|
||||
</TaskConfigProvider>
|
||||
</BulkActionsProvider>
|
||||
</KeyboardProvider>
|
||||
<WebSocketStatusProvider isConnected={isConnected}>
|
||||
<KeyboardProvider>
|
||||
<BulkActionsProvider>
|
||||
<TaskConfigProvider>
|
||||
<div className="min-h-screen bg-background">
|
||||
<Header />
|
||||
<main className="container mx-auto px-4 py-6">
|
||||
<KanbanBoard />
|
||||
</main>
|
||||
<Toaster />
|
||||
<KeyboardShortcutsDialog />
|
||||
</div>
|
||||
</TaskConfigProvider>
|
||||
</BulkActionsProvider>
|
||||
</KeyboardProvider>
|
||||
</WebSocketStatusProvider>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { ActivitySidebar } from './ActivitySidebar';
|
|||
import { ArchiveSidebar } from './ArchiveSidebar';
|
||||
import { UserMenu } from './UserMenu';
|
||||
import { AgentStatusIndicator } from '@/components/shared/AgentStatusIndicator';
|
||||
import { WebSocketIndicator } from '@/components/shared/WebSocketIndicator';
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useKeyboard } from '@/hooks/useKeyboard';
|
||||
|
||||
|
|
@ -36,6 +37,8 @@ export function Header() {
|
|||
</div>
|
||||
<div className="h-4 w-px bg-border" aria-hidden="true" />
|
||||
<AgentStatusIndicator />
|
||||
<div className="h-4 w-px bg-border" aria-hidden="true" />
|
||||
<WebSocketIndicator />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
|
|
|
|||
46
web/src/components/shared/WebSocketIndicator.tsx
Normal file
46
web/src/components/shared/WebSocketIndicator.tsx
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import { useWebSocketStatus } from '@/contexts/WebSocketContext';
|
||||
import { Wifi, WifiOff } from 'lucide-react';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
|
||||
/**
|
||||
* Tiny indicator showing WebSocket connection status.
|
||||
* Green dot + wifi icon = connected (real-time updates active)
|
||||
* Red dot + wifi-off icon = disconnected (falling back to polling)
|
||||
*/
|
||||
export function WebSocketIndicator() {
|
||||
const { isConnected } = useWebSocketStatus();
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground cursor-default select-none">
|
||||
<span
|
||||
className={`inline-block h-2 w-2 rounded-full ${
|
||||
isConnected
|
||||
? 'bg-green-500 shadow-[0_0_4px_rgba(34,197,94,0.5)]'
|
||||
: 'bg-red-500 shadow-[0_0_4px_rgba(239,68,68,0.4)]'
|
||||
}`}
|
||||
aria-label={isConnected ? 'WebSocket connected' : 'WebSocket disconnected'}
|
||||
/>
|
||||
{isConnected ? (
|
||||
<Wifi className="h-3 w-3 text-green-500" />
|
||||
) : (
|
||||
<WifiOff className="h-3 w-3 text-red-500" />
|
||||
)}
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="text-xs">
|
||||
{isConnected
|
||||
? 'Real-time sync active'
|
||||
: 'Disconnected — polling every 10s'}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
34
web/src/contexts/WebSocketContext.tsx
Normal file
34
web/src/contexts/WebSocketContext.tsx
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import { createContext, useContext, type ReactNode } from 'react';
|
||||
|
||||
interface WebSocketStatus {
|
||||
/** Whether the WebSocket is currently connected */
|
||||
isConnected: boolean;
|
||||
}
|
||||
|
||||
const WebSocketStatusContext = createContext<WebSocketStatus>({
|
||||
isConnected: false,
|
||||
});
|
||||
|
||||
export function WebSocketStatusProvider({
|
||||
children,
|
||||
isConnected,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
isConnected: boolean;
|
||||
}) {
|
||||
return (
|
||||
<WebSocketStatusContext.Provider value={{ isConnected }}>
|
||||
{children}
|
||||
</WebSocketStatusContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the current WebSocket connection status.
|
||||
* Used by data-fetching hooks to adjust polling intervals:
|
||||
* - Connected: reduce polling (WS handles real-time updates)
|
||||
* - Disconnected: increase polling as fallback
|
||||
*/
|
||||
export function useWebSocketStatus(): WebSocketStatus {
|
||||
return useContext(WebSocketStatusContext);
|
||||
}
|
||||
|
|
@ -6,8 +6,12 @@ import { useWebSocket, type WebSocketMessage } from './useWebSocket';
|
|||
* Connects to the Veritas Kanban WebSocket server and listens for
|
||||
* task:changed events. When received, invalidates the React Query
|
||||
* task cache so the board updates in real-time.
|
||||
*
|
||||
* Returns `isConnected` so the caller can provide it to
|
||||
* `WebSocketStatusProvider`, allowing data-fetching hooks to
|
||||
* reduce polling when the WebSocket is healthy.
|
||||
*/
|
||||
export function useTaskSync() {
|
||||
export function useTaskSync(): { isConnected: boolean } {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const handleMessage = useCallback((message: WebSocketMessage) => {
|
||||
|
|
@ -28,9 +32,11 @@ export function useTaskSync() {
|
|||
}
|
||||
}, [queryClient]);
|
||||
|
||||
useWebSocket({
|
||||
const { isConnected } = useWebSocket({
|
||||
onOpen: { type: 'subscribe:tasks' },
|
||||
onMessage: handleMessage,
|
||||
reconnectDelay: 3000,
|
||||
});
|
||||
|
||||
return { isConnected };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,26 @@
|
|||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { api } from '@/lib/api';
|
||||
import { useWebSocketStatus } from '@/contexts/WebSocketContext';
|
||||
import type { Task, CreateTaskInput, UpdateTaskInput } from '@veritas-kanban/shared';
|
||||
|
||||
/**
|
||||
* Polling intervals based on WebSocket connection state.
|
||||
* - Connected: 60s safety-net polling (WS delivers real-time updates)
|
||||
* - Disconnected: 10s aggressive polling as fallback
|
||||
*/
|
||||
const POLL_INTERVAL_WS_CONNECTED = 60_000;
|
||||
const POLL_INTERVAL_WS_DISCONNECTED = 10_000;
|
||||
|
||||
export function useTasks() {
|
||||
const { isConnected } = useWebSocketStatus();
|
||||
|
||||
return useQuery({
|
||||
queryKey: ['tasks'],
|
||||
queryFn: api.tasks.list,
|
||||
refetchInterval: 10000, // Poll every 10s as fallback (WebSocket handles instant updates)
|
||||
staleTime: 5000, // Consider data stale after 5s
|
||||
refetchInterval: isConnected
|
||||
? POLL_INTERVAL_WS_CONNECTED
|
||||
: POLL_INTERVAL_WS_DISCONNECTED,
|
||||
staleTime: isConnected ? 30_000 : 5_000,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -323,10 +336,13 @@ export function getTaskBlockers(task: Task, allTasks: Task[]): Task[] {
|
|||
|
||||
// Archive suggestions - sprints where all tasks are done
|
||||
export function useArchiveSuggestions() {
|
||||
const { isConnected } = useWebSocketStatus();
|
||||
|
||||
return useQuery({
|
||||
queryKey: ['tasks', 'archive-suggestions'],
|
||||
queryFn: api.tasks.getArchiveSuggestions,
|
||||
refetchInterval: 30000, // Check every 30s
|
||||
// When WS is connected, task:changed events trigger invalidation anyway
|
||||
refetchInterval: isConnected ? 120_000 : 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,6 @@
|
|||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { sanitizeHtml, sanitizeText } from '../sanitize';
|
||||
|
||||
|
|
@ -89,7 +92,10 @@ describe('sanitizeHtml', () => {
|
|||
it('strips nested script injection', () => {
|
||||
const input = '<div><scr<script>ipt>alert("xss")</scr</script>ipt></div>';
|
||||
const result = sanitizeHtml(input);
|
||||
expect(result).not.toContain('alert');
|
||||
// DOMPurify strips <script> tags. The residual text "ipt>alert(...)ipt>"
|
||||
// is safely escaped and NOT executable — no <script> element exists.
|
||||
expect(result).not.toContain('<script>');
|
||||
expect(result).not.toContain('</script>');
|
||||
});
|
||||
|
||||
it('strips SVG-based XSS', () => {
|
||||
|
|
@ -143,9 +149,12 @@ describe('sanitizeText', () => {
|
|||
it('handles Markdown-like XSS payloads', () => {
|
||||
const input = '[link](javascript:alert("xss"))';
|
||||
const result = sanitizeText(input);
|
||||
// As plain text, the markdown syntax is kept as literal characters
|
||||
// As plain text, sanitizeText strips HTML tags but markdown syntax is
|
||||
// NOT HTML — it's literal text. The "javascript:" is just a string,
|
||||
// not an href attribute. React's JSX escaping prevents it from becoming
|
||||
// an active link. This is safe.
|
||||
expect(result).not.toContain('<script>');
|
||||
expect(result).not.toContain('javascript:');
|
||||
expect(result).not.toContain('<a');
|
||||
});
|
||||
|
||||
it('handles empty/falsy input', () => {
|
||||
|
|
@ -159,6 +168,7 @@ describe('sanitizeText', () => {
|
|||
const result = sanitizeText(input);
|
||||
expect(result).not.toContain('onerror');
|
||||
expect(result).not.toContain('<img');
|
||||
expect(result).toBe('">');
|
||||
// DOMPurify HTML-encodes the remaining ">" as ">"
|
||||
expect(result).toBe('">');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -14,6 +14,11 @@
|
|||
|
||||
import DOMPurify from 'dompurify';
|
||||
|
||||
// Initialize DOMPurify — the default export auto-detects the browser window.
|
||||
// In test environments (jsdom), this also works because vitest provides a
|
||||
// window global when configured with environment: 'jsdom'.
|
||||
const purify = DOMPurify;
|
||||
|
||||
/**
|
||||
* Allowed HTML tags that are safe for Markdown-rendered content.
|
||||
* These cover standard Markdown output (headings, lists, emphasis, code, etc.).
|
||||
|
|
@ -88,7 +93,7 @@ const ALLOWED_URI_REGEXP = /^(?:(?:https?|mailto|tel|ftp):|[^a-z]|[a-z+.-]+(?:[^
|
|||
export function sanitizeHtml(dirty: string): string {
|
||||
if (!dirty) return '';
|
||||
|
||||
return DOMPurify.sanitize(dirty, {
|
||||
return purify.sanitize(dirty, {
|
||||
ALLOWED_TAGS,
|
||||
ALLOWED_ATTR,
|
||||
ALLOWED_URI_REGEXP,
|
||||
|
|
@ -120,7 +125,7 @@ export function sanitizeHtml(dirty: string): string {
|
|||
export function sanitizeText(dirty: string): string {
|
||||
if (!dirty) return '';
|
||||
|
||||
return DOMPurify.sanitize(dirty, {
|
||||
return purify.sanitize(dirty, {
|
||||
ALLOWED_TAGS: [], // Strip ALL tags
|
||||
ALLOWED_ATTR: [], // Strip ALL attributes
|
||||
KEEP_CONTENT: true, // Keep text content of stripped tags
|
||||
|
|
@ -131,7 +136,7 @@ export function sanitizeText(dirty: string): string {
|
|||
* Hook DOMPurify to enforce safe link targets.
|
||||
* All <a> tags get target="_blank" and rel="noopener noreferrer".
|
||||
*/
|
||||
DOMPurify.addHook('afterSanitizeAttributes', (node) => {
|
||||
purify.addHook('afterSanitizeAttributes', (node) => {
|
||||
if (node.tagName === 'A') {
|
||||
node.setAttribute('target', '_blank');
|
||||
node.setAttribute('rel', 'noopener noreferrer');
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue