From dc01fc13fdd9d3102da27c11d9560eacc28ce5fc Mon Sep 17 00:00:00 2001 From: Brad Groux Date: Wed, 28 Jan 2026 12:13:48 -0600 Subject: [PATCH] perf: reduce polling when WebSocket connected --- .../telemetry/events-2026-01-28.ndjson | 124 ++++++ pnpm-lock.yaml | 334 +++++++++++++++- .../src/__tests__/attachment-service.test.ts | 4 +- server/src/routes/attachments.ts | 21 +- server/src/services/attachment-service.ts | 52 ++- server/src/services/config-service.ts | 7 + server/src/services/mime-validation.ts | 372 ++++++++++++++++++ shared/src/types/task.types.ts | 2 + web/package.json | 1 + web/src/App.tsx | 33 +- web/src/components/layout/Header.tsx | 3 + .../components/shared/WebSocketIndicator.tsx | 46 +++ web/src/contexts/WebSocketContext.tsx | 34 ++ web/src/hooks/useTaskSync.ts | 10 +- web/src/hooks/useTasks.ts | 22 +- web/src/lib/__tests__/sanitize.test.ts | 18 +- web/src/lib/sanitize.ts | 11 +- 17 files changed, 1050 insertions(+), 44 deletions(-) create mode 100644 server/src/services/mime-validation.ts create mode 100644 web/src/components/shared/WebSocketIndicator.tsx create mode 100644 web/src/contexts/WebSocketContext.tsx diff --git a/.veritas-kanban/telemetry/events-2026-01-28.ndjson b/.veritas-kanban/telemetry/events-2026-01-28.ndjson index 9d968ee3..8ca48ed7 100644 --- a/.veritas-kanban/telemetry/events-2026-01-28.ndjson +++ b/.veritas-kanban/telemetry/events-2026-01-28.ndjson @@ -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"} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c497257c..3b013468 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: {} diff --git a/server/src/__tests__/attachment-service.test.ts b/server/src/__tests__/attachment-service.test.ts index db0d3b22..3422de0d 100644 --- a/server/src/__tests__/attachment-service.test.ts +++ b/server/src/__tests__/attachment-service.test.ts @@ -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/ ); }); diff --git a/server/src/routes/attachments.ts b/server/src/routes/attachments.ts index 6f804185..34030283 100644 --- a/server/src/routes/attachments.ts +++ b/server/src/routes/attachments.ts @@ -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); diff --git a/server/src/services/attachment-service.ts b/server/src/services/attachment-service.ts index 2a4b5bd7..c5b73915 100644 --- a/server/src/services/attachment-service.ts +++ b/server/src/services/attachment-service.ts @@ -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 { + // 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 { - // 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(), }; diff --git a/server/src/services/config-service.ts b/server/src/services/config-service.ts index 8f8bca66..6c9d3f0d 100644 --- a/server/src/services/config-service.ts +++ b/server/src/services/config-service.ts @@ -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'); diff --git a/server/src/services/mime-validation.ts b/server/src/services/mime-validation.ts new file mode 100644 index 00000000..c254e9d3 --- /dev/null +++ b/server/src/services/mime-validation.ts @@ -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 = { + // ── 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 { + const map = new Map(); + 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 { + 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(); + 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('; '); +} diff --git a/shared/src/types/task.types.ts b/shared/src/types/task.types.ts index 46d6a69b..a96b8603 100644 --- a/shared/src/types/task.types.ts +++ b/shared/src/types/task.types.ts @@ -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', diff --git a/web/package.json b/web/package.json index 7a3c64c5..5dfcf087 100644 --- a/web/package.json +++ b/web/package.json @@ -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", diff --git a/web/src/App.tsx b/web/src/App.tsx index 9bebaadf..226b0e86 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -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 ( - - - -
-
-
- -
- - -
-
-
-
+ + + + +
+
+
+ +
+ + +
+
+
+
+
); } diff --git a/web/src/components/layout/Header.tsx b/web/src/components/layout/Header.tsx index 61e5bd09..0d69fd51 100644 --- a/web/src/components/layout/Header.tsx +++ b/web/src/components/layout/Header.tsx @@ -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() {