perf: reduce polling when WebSocket connected

This commit is contained in:
Brad Groux 2026-01-28 12:13:48 -06:00
parent 9422e38cc9
commit dc01fc13fd
17 changed files with 1050 additions and 44 deletions

View file

@ -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
View file

@ -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: {}

View file

@ -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/
);
});

View file

@ -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);

View file

@ -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(),
};

View file

@ -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');

View 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('; ');
}

View file

@ -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',

View file

@ -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",

View file

@ -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>
);
}

View file

@ -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">

View 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>
);
}

View 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);
}

View file

@ -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 };
}

View file

@ -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,
});
}

View file

@ -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 "&gt;"
expect(result).toBe('"&gt;');
});
});

View file

@ -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');