mirror of
https://github.com/BradGroux/veritas-kanban.git
synced 2026-10-08 20:57:51 +00:00
feat(server): implement per-route rate limiting with tiered thresholds
This commit is contained in:
parent
2eef45a5cd
commit
6640c4986d
14 changed files with 1075 additions and 164 deletions
|
|
@ -1272,3 +1272,192 @@
|
|||
{"type":"task.created","taskId":"task_20260129_T0fxZb","status":"todo","id":"evt_RGyZgnNhv1HO","timestamp":"2026-01-29T10:37:07.020Z"}
|
||||
{"type":"task.status_changed","taskId":"task_20260129_H_qzbf","status":"done","previousStatus":"in-progress","id":"evt_j0AX9HSBM57P","timestamp":"2026-01-29T10:37:21.101Z"}
|
||||
{"type":"task.status_changed","taskId":"task_20260129_41Y8FT","status":"done","previousStatus":"in-progress","id":"evt_RtEQUD_Mi6Kf","timestamp":"2026-01-29T10:39:21.292Z"}
|
||||
{"type":"task.status_changed","taskId":"task_20260129_9nk1AM","status":"in-progress","previousStatus":"todo","id":"evt_i-b0PTVN3dun","timestamp":"2026-01-29T10:39:59.821Z"}
|
||||
{"type":"task.status_changed","taskId":"task_20260129_0nTA6h","status":"in-progress","previousStatus":"todo","id":"evt_0nRsU14pYpI_","timestamp":"2026-01-29T10:39:59.841Z"}
|
||||
{"type":"task.status_changed","taskId":"task_20260126_legacy1","status":"blocked","previousStatus":"review","id":"evt_7BX4UCHW_gcS","timestamp":"2026-01-29T10:42:44.201Z"}
|
||||
{"type":"task.created","taskId":"task_20260129_eYyqYc","status":"todo","id":"evt_vAVzuklczdUy","timestamp":"2026-01-29T10:42:44.207Z"}
|
||||
{"type":"task.created","taskId":"task_20260129_2tB7VH","status":"todo","id":"evt_fG3g3iO-X_9j","timestamp":"2026-01-29T10:42:44.215Z"}
|
||||
{"type":"task.status_changed","taskId":"task_20260129_2tB7VH","status":"in-progress","previousStatus":"todo","id":"evt_Fup52ZiCF2WI","timestamp":"2026-01-29T10:42:44.218Z"}
|
||||
{"type":"task.created","taskId":"task_20260129_MoA-tn","status":"todo","id":"evt_8nFxFdqd7Lsb","timestamp":"2026-01-29T10:42:44.221Z"}
|
||||
{"type":"task.created","taskId":"task_20260129_L-g08p","project":"my-project","status":"todo","id":"evt_ysM8W4OHX5PA","timestamp":"2026-01-29T10:42:44.220Z"}
|
||||
{"type":"task.status_changed","taskId":"task_20260129_MoA-tn","status":"blocked","previousStatus":"todo","id":"evt_1gilcvvA78OZ","timestamp":"2026-01-29T10:42:44.223Z"}
|
||||
{"type":"task.created","taskId":"task_20260129_hC3STo","status":"todo","id":"evt_A16n1ISPgPMu","timestamp":"2026-01-29T10:42:44.231Z"}
|
||||
{"type":"task.status_changed","taskId":"task_20260129_hC3STo","status":"done","previousStatus":"todo","id":"evt_pRNcpSdSzc-g","timestamp":"2026-01-29T10:42:44.232Z"}
|
||||
{"type":"task.created","taskId":"task_20260129_dl7IKa","status":"todo","id":"evt_b5fjlyNUQqTA","timestamp":"2026-01-29T10:42:44.235Z"}
|
||||
{"type":"task.status_changed","taskId":"task_20260126_idem1","status":"blocked","previousStatus":"review","id":"evt_BsoWV6D3WhN-","timestamp":"2026-01-29T10:42:44.240Z"}
|
||||
{"type":"task.created","taskId":"task_20260129_OPfbK2","status":"todo","id":"evt_On-ayGZ9PInK","timestamp":"2026-01-29T10:42:44.246Z"}
|
||||
{"type":"task.status_changed","taskId":"task_20260126_multi1","status":"blocked","previousStatus":"review","id":"evt_xnOFZTKTpf1b","timestamp":"2026-01-29T10:42:44.267Z"}
|
||||
{"type":"task.created","taskId":"task_20260129_4lRH18","status":"todo","id":"evt_bLGm_m5U2SOp","timestamp":"2026-01-29T10:42:44.268Z"}
|
||||
{"type":"task.status_changed","taskId":"task_20260126_multi2","status":"blocked","previousStatus":"review","id":"evt_SxPfjcjbjSxg","timestamp":"2026-01-29T10:42:44.270Z"}
|
||||
{"type":"task.status_changed","taskId":"task_20260129_4lRH18","status":"in-progress","previousStatus":"todo","id":"evt_a_Gg_qv0ZSZh","timestamp":"2026-01-29T10:42:44.281Z"}
|
||||
{"type":"task.created","taskId":"task_20260129_TAKpHi","status":"todo","id":"evt_EY6v4e14_5Md","timestamp":"2026-01-29T10:42:44.300Z"}
|
||||
{"type":"task.created","taskId":"task_20260129_PJIGXf","status":"todo","id":"evt_rgmPmqUkaffv","timestamp":"2026-01-29T10:42:44.305Z"}
|
||||
{"type":"task.created","taskId":"task_20260129_z4Mzd4","status":"todo","id":"evt_yigMceivxz2h","timestamp":"2026-01-29T10:42:44.309Z"}
|
||||
{"type":"task.created","taskId":"task_20260129_dydFuM","status":"todo","id":"evt_O9W0Q80OLSUB","timestamp":"2026-01-29T10:42:44.320Z"}
|
||||
{"type":"task.created","taskId":"task_20260129_y1xcx4","status":"todo","id":"evt_xiJfBDHd_jqj","timestamp":"2026-01-29T10:42:44.336Z"}
|
||||
{"type":"task.created","taskId":"task_20260129_gIcdOo","status":"todo","id":"evt_9erwsulRisnE","timestamp":"2026-01-29T10:42:44.339Z"}
|
||||
{"type":"task.created","taskId":"task_20260129_IJh4Vm","status":"todo","id":"evt_RcDMFIQxD_hT","timestamp":"2026-01-29T10:42:44.355Z"}
|
||||
{"type":"task.created","taskId":"task_20260129_z6TLuh","status":"todo","id":"evt_G3Ept7rF118Q","timestamp":"2026-01-29T10:42:44.357Z"}
|
||||
{"type":"task.created","taskId":"task_20260129_asIbfo","status":"todo","id":"evt_3144tFc4UnV1","timestamp":"2026-01-29T10:42:44.371Z"}
|
||||
{"type":"task.created","taskId":"task_20260129_rXr07k","status":"todo","id":"evt_1TblR5oRb_bw","timestamp":"2026-01-29T10:42:44.377Z"}
|
||||
{"type":"task.created","taskId":"task_20260129_mst8dr","project":"test-project","status":"todo","id":"evt_ndLkvWckICTG","timestamp":"2026-01-29T10:42:44.378Z"}
|
||||
{"type":"task.created","taskId":"task_20260129_RwBV3M","status":"todo","id":"evt_BSS5GW7lfWwD","timestamp":"2026-01-29T10:42:44.384Z"}
|
||||
{"type":"task.created","taskId":"task_20260129_nzEXKe","status":"todo","id":"evt_ekX8LlzNGx7x","timestamp":"2026-01-29T10:42:44.385Z"}
|
||||
{"type":"task.created","taskId":"task_20260129_RIuZmy","status":"todo","id":"evt_5TxSrZItE7Sz","timestamp":"2026-01-29T10:42:44.390Z"}
|
||||
{"type":"task.created","taskId":"task_20260129_mkTCbG","status":"todo","id":"evt_43zHaAFE2ZR9","timestamp":"2026-01-29T10:42:44.396Z"}
|
||||
{"type":"task.created","taskId":"task_20260129_CUl1BE","status":"todo","id":"evt_S-Hp6Sad_F5g","timestamp":"2026-01-29T10:42:44.397Z"}
|
||||
{"type":"task.status_changed","taskId":"task_20260129_mkTCbG","status":"in-progress","previousStatus":"todo","id":"evt_SPZ1XvxSjNAm","timestamp":"2026-01-29T10:42:44.403Z"}
|
||||
{"type":"task.created","taskId":"task_20260129_go5pvJ","status":"todo","id":"evt_irvJLuxjOlmr","timestamp":"2026-01-29T10:42:44.406Z"}
|
||||
{"type":"task.created","taskId":"task_20260129_RRS3dW","status":"todo","id":"evt_5NBvVXNOebaF","timestamp":"2026-01-29T10:42:44.406Z"}
|
||||
{"type":"task.created","taskId":"task_20260129_RGk8Ww","status":"todo","id":"evt_OFrEXzgEzwcE","timestamp":"2026-01-29T10:42:44.415Z"}
|
||||
{"type":"task.created","taskId":"task_20260129_Z2ugJr","status":"todo","id":"evt_b1wfEqEjIZMV","timestamp":"2026-01-29T10:42:44.415Z"}
|
||||
{"type":"task.created","taskId":"task_20260129_YY6rZq","status":"todo","id":"evt_Fz_d4wfOKMEL","timestamp":"2026-01-29T10:42:44.432Z"}
|
||||
{"type":"task.created","taskId":"task_20260129_tAsecI","status":"todo","id":"evt_HHNU0eZUOMYn","timestamp":"2026-01-29T10:42:44.436Z"}
|
||||
{"type":"task.created","taskId":"task_20260129_44uEFR","status":"todo","id":"evt_kf_wzFbDERic","timestamp":"2026-01-29T10:42:44.436Z"}
|
||||
{"type":"task.restored","taskId":"task_20260126_arch1","status":"done","id":"evt_gn18kMk-5JDZ","timestamp":"2026-01-29T10:42:44.446Z"}
|
||||
{"type":"task.created","taskId":"task_20260129_PN12Ja","status":"todo","id":"evt_0m6e0NQxEtfv","timestamp":"2026-01-29T10:42:44.447Z"}
|
||||
{"type":"task.status_changed","taskId":"task_20260126_arch1","status":"blocked","previousStatus":"done","id":"evt_UcqQPEF9XSN0","timestamp":"2026-01-29T10:42:44.449Z"}
|
||||
{"type":"task.archived","taskId":"task_20260126_arch1","status":"blocked","id":"evt_Z9CHLFkTZdbk","timestamp":"2026-01-29T10:42:44.450Z"}
|
||||
{"type":"task.created","taskId":"task_20260129_ew3f1e","status":"todo","id":"evt_3WgE6WRz0oWW","timestamp":"2026-01-29T10:42:44.451Z"}
|
||||
{"type":"task.archived","taskId":"task_20260129_ew3f1e","status":"todo","id":"evt_J-DzCUNEZ2d5","timestamp":"2026-01-29T10:42:44.454Z"}
|
||||
{"type":"task.created","taskId":"task_20260129_AKW0hr","status":"todo","id":"evt_FakoiTiDKsrQ","timestamp":"2026-01-29T10:42:44.454Z"}
|
||||
{"type":"task.created","taskId":"task_20260129_-4cg3Z","status":"todo","id":"evt_xrsAXqtOjngm","timestamp":"2026-01-29T10:42:44.459Z"}
|
||||
{"type":"task.created","taskId":"task_20260129_Xz5Qcn","status":"todo","id":"evt_3dAjqY5MpPpn","timestamp":"2026-01-29T10:42:44.461Z"}
|
||||
{"type":"task.created","taskId":"task_20260129_RC0M2O","status":"todo","id":"evt_7LQjaVNiWM-b","timestamp":"2026-01-29T10:42:44.471Z"}
|
||||
{"type":"task.created","taskId":"task_20260129_mDNzZY","status":"todo","id":"evt_FYHwOxvsNz_z","timestamp":"2026-01-29T10:42:44.472Z"}
|
||||
{"type":"task.created","taskId":"task_20260129_xT2ksA","status":"todo","id":"evt_qxZSnGhqWEJn","timestamp":"2026-01-29T10:42:44.488Z"}
|
||||
{"type":"task.created","taskId":"task_20260129_75oLQI","status":"todo","id":"evt_pz8D9ZnXLQZr","timestamp":"2026-01-29T10:42:44.499Z"}
|
||||
{"type":"task.created","taskId":"task_20260129_TXPLjr","status":"todo","id":"evt_5Big2FFe0PN3","timestamp":"2026-01-29T10:42:44.500Z"}
|
||||
{"type":"task.created","taskId":"task_20260129_0x0pE6","status":"todo","id":"evt_-OR4ikSNHQr7","timestamp":"2026-01-29T10:42:44.503Z"}
|
||||
{"type":"task.created","taskId":"task_20260129_v_TW97","status":"todo","id":"evt_MJuAb-BMEN1f","timestamp":"2026-01-29T10:42:44.503Z"}
|
||||
{"type":"task.created","taskId":"task_20260129_T7exU5","status":"todo","id":"evt_jSuNBHLLwIld","timestamp":"2026-01-29T10:42:44.505Z"}
|
||||
{"type":"task.created","taskId":"task_20260129_pADUvF","status":"todo","id":"evt_NGDmL64vizrA","timestamp":"2026-01-29T10:42:44.526Z"}
|
||||
{"type":"task.created","taskId":"task_20260129_gp5xkb","project":"project-a","status":"todo","id":"evt_wNqhw3sPwhI-","timestamp":"2026-01-29T10:42:44.540Z"}
|
||||
{"type":"task.created","taskId":"task_20260129_ViosAs","project":"project-a","status":"todo","id":"evt_E2PdPBzUEfkq","timestamp":"2026-01-29T10:42:44.547Z"}
|
||||
{"type":"task.created","taskId":"task_20260129_5mJRM3","project":"project-b","status":"todo","id":"evt_tGLugrBWmEey","timestamp":"2026-01-29T10:42:44.554Z"}
|
||||
{"type":"task.created","taskId":"task_20260129_6w9pt3","status":"todo","id":"evt_oADTXfwrC0Hh","timestamp":"2026-01-29T10:42:44.586Z"}
|
||||
{"type":"task.status_changed","taskId":"task_20260126_legacy1","status":"blocked","previousStatus":"review","id":"evt_n89RzpqGg6jG","timestamp":"2026-01-29T10:43:20.564Z"}
|
||||
{"type":"task.created","taskId":"task_20260129_ucQlf4","status":"todo","id":"evt_NSpw4zojUvGj","timestamp":"2026-01-29T10:43:20.571Z"}
|
||||
{"type":"task.created","taskId":"task_20260129_KMu7HU","project":"my-project","status":"todo","id":"evt_UVhVCW29HDEI","timestamp":"2026-01-29T10:43:20.575Z"}
|
||||
{"type":"task.created","taskId":"task_20260129_BBINit","status":"todo","id":"evt_6BY7yWB0oMbg","timestamp":"2026-01-29T10:43:20.578Z"}
|
||||
{"type":"task.status_changed","taskId":"task_20260129_BBINit","status":"in-progress","previousStatus":"todo","id":"evt_QT0sm73GVfNY","timestamp":"2026-01-29T10:43:20.580Z"}
|
||||
{"type":"task.created","taskId":"task_20260129_KCa1c7","status":"todo","id":"evt_zi6Jl_NZwp08","timestamp":"2026-01-29T10:43:20.581Z"}
|
||||
{"type":"task.status_changed","taskId":"task_20260129_KCa1c7","status":"blocked","previousStatus":"todo","id":"evt_VSD84xBg7BYN","timestamp":"2026-01-29T10:43:20.583Z"}
|
||||
{"type":"task.created","taskId":"task_20260129_ovldZk","status":"todo","id":"evt_YEogQmJxZsBD","timestamp":"2026-01-29T10:43:20.583Z"}
|
||||
{"type":"task.created","taskId":"task_20260129_VFn0L8","status":"todo","id":"evt_CtMayszOk1IT","timestamp":"2026-01-29T10:43:20.584Z"}
|
||||
{"type":"task.status_changed","taskId":"task_20260129_VFn0L8","status":"done","previousStatus":"todo","id":"evt_R8K5rBdVFjxe","timestamp":"2026-01-29T10:43:20.586Z"}
|
||||
{"type":"task.created","taskId":"task_20260129_E6Q2AK","status":"todo","id":"evt_MDRC7JTigkj8","timestamp":"2026-01-29T10:43:20.587Z"}
|
||||
{"type":"task.created","taskId":"task_20260129_68AW3n","status":"todo","id":"evt_b7_yQ_U_iaea","timestamp":"2026-01-29T10:43:20.591Z"}
|
||||
{"type":"task.status_changed","taskId":"task_20260126_idem1","status":"blocked","previousStatus":"review","id":"evt_y3z80Fvxpi2b","timestamp":"2026-01-29T10:43:20.597Z"}
|
||||
{"type":"task.created","taskId":"task_20260129_xW1YOp","status":"todo","id":"evt_O0NxwehbNzcd","timestamp":"2026-01-29T10:43:20.597Z"}
|
||||
{"type":"task.status_changed","taskId":"task_20260129_68AW3n","status":"in-progress","previousStatus":"todo","id":"evt_Dz1KCctSDKKc","timestamp":"2026-01-29T10:43:20.605Z"}
|
||||
{"type":"task.created","taskId":"task_20260129_5kIhCg","status":"todo","id":"evt_iQ6LmtsVl2ZA","timestamp":"2026-01-29T10:43:20.609Z"}
|
||||
{"type":"task.status_changed","taskId":"task_20260126_multi2","status":"blocked","previousStatus":"review","id":"evt_GB1xheiY241K","timestamp":"2026-01-29T10:43:20.611Z"}
|
||||
{"type":"task.status_changed","taskId":"task_20260126_multi1","status":"blocked","previousStatus":"review","id":"evt_cC0hptApNqgM","timestamp":"2026-01-29T10:43:20.612Z"}
|
||||
{"type":"task.created","taskId":"task_20260129_7ch05s","status":"todo","id":"evt_r6V4jFomSWuk","timestamp":"2026-01-29T10:43:20.612Z"}
|
||||
{"type":"task.created","taskId":"task_20260129_Gq6ymg","status":"todo","id":"evt_2jpmamzNKfY0","timestamp":"2026-01-29T10:43:20.612Z"}
|
||||
{"type":"task.created","taskId":"task_20260129_4Gk74K","status":"todo","id":"evt_N6i2qYSGqoH9","timestamp":"2026-01-29T10:43:20.619Z"}
|
||||
{"type":"task.created","taskId":"task_20260129_OkcaeR","status":"todo","id":"evt_nhgSb0JAL1Yx","timestamp":"2026-01-29T10:43:20.622Z"}
|
||||
{"type":"task.created","taskId":"task_20260129_QcdOix","status":"todo","id":"evt_sXNEGAsIdRvX","timestamp":"2026-01-29T10:43:20.631Z"}
|
||||
{"type":"task.created","taskId":"task_20260129_Vc3vBA","status":"todo","id":"evt_SrYqr_zjVeNM","timestamp":"2026-01-29T10:43:20.633Z"}
|
||||
{"type":"task.created","taskId":"task_20260129_inf05v","project":"test-project","status":"todo","id":"evt_xzfXR4PRwFmR","timestamp":"2026-01-29T10:43:20.642Z"}
|
||||
{"type":"task.created","taskId":"task_20260129_S1T_dk","status":"todo","id":"evt_5bcGVZBQYnis","timestamp":"2026-01-29T10:43:20.650Z"}
|
||||
{"type":"task.created","taskId":"task_20260129_-aAJRa","status":"todo","id":"evt_sHR9N14Z-cUG","timestamp":"2026-01-29T10:43:20.650Z"}
|
||||
{"type":"task.created","taskId":"task_20260129_ci43KL","status":"todo","id":"evt_giP_O2ODvPP1","timestamp":"2026-01-29T10:43:20.650Z"}
|
||||
{"type":"task.created","taskId":"task_20260129_CqAHD_","status":"todo","id":"evt_UBNwvjVx0jOM","timestamp":"2026-01-29T10:43:20.666Z"}
|
||||
{"type":"task.status_changed","taskId":"task_20260129_CqAHD_","status":"in-progress","previousStatus":"todo","id":"evt_80G3aIV0_34z","timestamp":"2026-01-29T10:43:20.671Z"}
|
||||
{"type":"task.restored","taskId":"task_20260126_arch1","status":"done","id":"evt_LsZnSy3t92xs","timestamp":"2026-01-29T10:43:20.676Z"}
|
||||
{"type":"task.created","taskId":"task_20260129_deco_5","status":"todo","id":"evt_65gm4Jvdbn6o","timestamp":"2026-01-29T10:43:20.679Z"}
|
||||
{"type":"task.status_changed","taskId":"task_20260126_arch1","status":"blocked","previousStatus":"done","id":"evt_5h0MQJteajYw","timestamp":"2026-01-29T10:43:20.679Z"}
|
||||
{"type":"task.created","taskId":"task_20260129_hBwo_6","status":"todo","id":"evt_Hd9BBsFI-rHL","timestamp":"2026-01-29T10:43:20.680Z"}
|
||||
{"type":"task.archived","taskId":"task_20260126_arch1","status":"blocked","id":"evt_4vzuH2tVsQ4H","timestamp":"2026-01-29T10:43:20.681Z"}
|
||||
{"type":"task.created","taskId":"task_20260129_jlNxKZ","status":"todo","id":"evt_U3PlacGDMo4U","timestamp":"2026-01-29T10:43:20.683Z"}
|
||||
{"type":"task.created","taskId":"task_20260129_64wrk_","status":"todo","id":"evt_sQhv4Uun7LPr","timestamp":"2026-01-29T10:43:20.687Z"}
|
||||
{"type":"task.created","taskId":"task_20260129_6Z8nKu","status":"todo","id":"evt_fOoQWqOJ_Bjw","timestamp":"2026-01-29T10:43:20.688Z"}
|
||||
{"type":"task.created","taskId":"task_20260129_b7DN6X","status":"todo","id":"evt_p9mTa0UCvbly","timestamp":"2026-01-29T10:43:20.688Z"}
|
||||
{"type":"task.archived","taskId":"task_20260129_64wrk_","status":"todo","id":"evt_oZ7na0JWfOLw","timestamp":"2026-01-29T10:43:20.691Z"}
|
||||
{"type":"task.created","taskId":"task_20260129_V6O45w","status":"todo","id":"evt_z6DVnINU4u-x","timestamp":"2026-01-29T10:43:20.697Z"}
|
||||
{"type":"task.created","taskId":"task_20260129_L36ufl","status":"todo","id":"evt_5ynszaC2nECG","timestamp":"2026-01-29T10:43:20.698Z"}
|
||||
{"type":"task.created","taskId":"task_20260129_LwgFrY","status":"todo","id":"evt_j2EfOHtQzO0m","timestamp":"2026-01-29T10:43:20.706Z"}
|
||||
{"type":"task.created","taskId":"task_20260129_azr4O_","status":"todo","id":"evt_htxlQ2VWFKLB","timestamp":"2026-01-29T10:43:20.709Z"}
|
||||
{"type":"task.created","taskId":"task_20260129_nwWUY9","status":"todo","id":"evt_b0QQtL8aZI6-","timestamp":"2026-01-29T10:43:20.725Z"}
|
||||
{"type":"task.created","taskId":"task_20260129_O1LLxF","status":"todo","id":"evt_Inkszzu_pqTF","timestamp":"2026-01-29T10:43:20.737Z"}
|
||||
{"type":"task.created","taskId":"task_20260129_eMzPNA","status":"todo","id":"evt_eRMm6jlPTNj3","timestamp":"2026-01-29T10:43:20.738Z"}
|
||||
{"type":"task.created","taskId":"task_20260129_Fofy-Q","status":"todo","id":"evt_YYMTcGP3qtJD","timestamp":"2026-01-29T10:43:20.750Z"}
|
||||
{"type":"task.created","taskId":"task_20260129_38hit0","status":"todo","id":"evt_yoQ7pXSA_L1A","timestamp":"2026-01-29T10:43:20.752Z"}
|
||||
{"type":"task.created","taskId":"task_20260129_Ez8pY5","status":"todo","id":"evt_bIt_2vN_2OeV","timestamp":"2026-01-29T10:43:20.766Z"}
|
||||
{"type":"task.created","taskId":"task_20260129_HNEBW4","status":"todo","id":"evt_XYNRXR81EUN-","timestamp":"2026-01-29T10:43:20.768Z"}
|
||||
{"type":"task.created","taskId":"task_20260129_yAoVqa","status":"todo","id":"evt_j-GbEuM50VjZ","timestamp":"2026-01-29T10:43:20.770Z"}
|
||||
{"type":"task.created","taskId":"task_20260129_DTDJYc","status":"todo","id":"evt_IKKOkixZ6jrG","timestamp":"2026-01-29T10:43:20.775Z"}
|
||||
{"type":"task.created","taskId":"task_20260129_M9wtaT","status":"todo","id":"evt_CmemiTlebAaa","timestamp":"2026-01-29T10:43:20.780Z"}
|
||||
{"type":"task.created","taskId":"task_20260129_jj__Oa","status":"todo","id":"evt_w2P6cq5wTwsi","timestamp":"2026-01-29T10:43:20.783Z"}
|
||||
{"type":"task.created","taskId":"task_20260129_LC-cg9","status":"todo","id":"evt_eOPLTPvUSH63","timestamp":"2026-01-29T10:43:20.784Z"}
|
||||
{"type":"task.created","taskId":"task_20260129_Asw50X","status":"todo","id":"evt_sR9xMwZRnTtM","timestamp":"2026-01-29T10:43:20.805Z"}
|
||||
{"type":"task.created","taskId":"task_20260129_7FB3cK","status":"todo","id":"evt_wJbXO3X0dO0a","timestamp":"2026-01-29T10:43:20.805Z"}
|
||||
{"type":"task.created","taskId":"task_20260129_S61KI9","project":"project-a","status":"todo","id":"evt_MIPbetRSJ5CT","timestamp":"2026-01-29T10:43:20.822Z"}
|
||||
{"type":"task.created","taskId":"task_20260129_3mtDKX","project":"project-a","status":"todo","id":"evt_qTCy5vChPECe","timestamp":"2026-01-29T10:43:20.825Z"}
|
||||
{"type":"task.created","taskId":"task_20260129_IQwpUe","project":"project-b","status":"todo","id":"evt_vWxCT6wKc83p","timestamp":"2026-01-29T10:43:20.828Z"}
|
||||
{"type":"task.created","taskId":"task_20260129_TFf-No","status":"todo","id":"evt_lWIPfKlLlWTX","timestamp":"2026-01-29T10:43:20.856Z"}
|
||||
{"type":"task.status_changed","taskId":"task_20260129_0nTA6h","status":"done","previousStatus":"in-progress","id":"evt_OzDCCyOxNSNO","timestamp":"2026-01-29T10:43:36.842Z"}
|
||||
{"type":"task.created","taskId":"task_20260129_SujB4w","status":"todo","id":"evt_sdsF-6Fnkd-U","timestamp":"2026-01-29T10:43:38.896Z"}
|
||||
{"type":"task.created","taskId":"task_20260129_iEIggN","status":"todo","id":"evt__KgOJm3DRjHL","timestamp":"2026-01-29T10:43:38.987Z"}
|
||||
{"type":"task.created","taskId":"task_20260129_fXAewy","status":"todo","id":"evt_HUN1P98qPND2","timestamp":"2026-01-29T10:43:38.988Z"}
|
||||
{"type":"task.created","taskId":"task_20260129_JDPYBz","status":"todo","id":"evt_PhLQPa-ImAmn","timestamp":"2026-01-29T10:43:38.996Z"}
|
||||
{"type":"task.created","taskId":"task_20260129__Vrcy9","status":"todo","id":"evt_SsYK4Wn4PSjQ","timestamp":"2026-01-29T10:43:39.015Z"}
|
||||
{"type":"task.created","taskId":"task_20260129_s2VIC8","status":"todo","id":"evt_E2L71bbpIlUR","timestamp":"2026-01-29T10:43:39.026Z"}
|
||||
{"type":"task.created","taskId":"task_20260129_seW98D","project":"test-project","status":"todo","id":"evt_1o8NL3Sfu1vo","timestamp":"2026-01-29T10:43:39.037Z"}
|
||||
{"type":"task.created","taskId":"task_20260129_Ff4E4a","status":"todo","id":"evt_SNFdF6E5pLbC","timestamp":"2026-01-29T10:43:39.048Z"}
|
||||
{"type":"task.created","taskId":"task_20260129_70Gn-N","status":"todo","id":"evt_Lb0rQzWl0y2j","timestamp":"2026-01-29T10:43:39.062Z"}
|
||||
{"type":"task.created","taskId":"task_20260129_qNqJP0","status":"todo","id":"evt_mJINDaew-Bap","timestamp":"2026-01-29T10:43:39.071Z"}
|
||||
{"type":"task.status_changed","taskId":"task_20260129_qNqJP0","status":"in-progress","previousStatus":"todo","id":"evt_TUNKAG-dfdqU","timestamp":"2026-01-29T10:43:39.080Z"}
|
||||
{"type":"task.created","taskId":"task_20260129_6PUifb","status":"todo","id":"evt_qK7aatS51Tgr","timestamp":"2026-01-29T10:43:39.083Z"}
|
||||
{"type":"task.created","taskId":"task_20260129_AJ_dfl","status":"todo","id":"evt_w207bDon7rWa","timestamp":"2026-01-29T10:43:39.101Z"}
|
||||
{"type":"task.created","taskId":"task_20260129_RElZ31","status":"todo","id":"evt_RSWmNTux91JA","timestamp":"2026-01-29T10:43:39.116Z"}
|
||||
{"type":"task.created","taskId":"task_20260129_MIH1RQ","status":"todo","id":"evt_MYU9VCCnwV4d","timestamp":"2026-01-29T10:43:39.125Z"}
|
||||
{"type":"task.created","taskId":"task_20260129_0Rhoxn","status":"todo","id":"evt_gcIk6uyAg7gh","timestamp":"2026-01-29T10:43:39.139Z"}
|
||||
{"type":"task.created","taskId":"task_20260129_vn3atG","status":"todo","id":"evt_XeY0TKPIecGE","timestamp":"2026-01-29T10:43:39.152Z"}
|
||||
{"type":"task.created","taskId":"task_20260129_VtN17t","status":"todo","id":"evt_sWrPZTVKDjEz","timestamp":"2026-01-29T10:43:39.163Z"}
|
||||
{"type":"task.created","taskId":"task_20260129_5CiLry","status":"todo","id":"evt_qBR7B2gVPwo7","timestamp":"2026-01-29T10:43:39.178Z"}
|
||||
{"type":"task.created","taskId":"task_20260129_vIe3x9","status":"todo","id":"evt_acf8i2zMoBan","timestamp":"2026-01-29T10:43:39.197Z"}
|
||||
{"type":"task.created","taskId":"task_20260129_zyy5Ir","status":"todo","id":"evt_Q_Q1YfTG2oWE","timestamp":"2026-01-29T10:43:39.222Z"}
|
||||
{"type":"task.created","taskId":"task_20260129_-TQWA5","project":"project-a","status":"todo","id":"evt_JRnpx00LNPsk","timestamp":"2026-01-29T10:43:39.246Z"}
|
||||
{"type":"task.created","taskId":"task_20260129_roP-7L","project":"project-a","status":"todo","id":"evt_8YTajdbeKQHZ","timestamp":"2026-01-29T10:43:39.248Z"}
|
||||
{"type":"task.created","taskId":"task_20260129_R5AhDJ","project":"project-b","status":"todo","id":"evt__zCgR1TrOyD_","timestamp":"2026-01-29T10:43:39.250Z"}
|
||||
{"type":"task.created","taskId":"task_20260129_HkgOL8","status":"todo","id":"evt_UWUW-kcuK9Ry","timestamp":"2026-01-29T10:43:39.272Z"}
|
||||
{"type":"task.created","taskId":"task_20260129_ZyHQLm","status":"todo","id":"evt_ioRbB3TJH0vH","timestamp":"2026-01-29T10:43:39.344Z"}
|
||||
{"type":"task.created","taskId":"task_20260129_pqFSl0","status":"todo","id":"evt_8LvyxbayeKuw","timestamp":"2026-01-29T10:43:39.350Z"}
|
||||
{"type":"task.created","taskId":"task_20260129_gTuKWZ","status":"todo","id":"evt_m7Xd0-uAMMeE","timestamp":"2026-01-29T10:43:39.355Z"}
|
||||
{"type":"task.created","taskId":"task_20260129_7o2P00","project":"my-project","status":"todo","id":"evt_nmqK7lx_SoNO","timestamp":"2026-01-29T10:43:39.374Z"}
|
||||
{"type":"task.created","taskId":"task_20260129_NJTwlO","status":"todo","id":"evt_dXC7ZrgbysYq","timestamp":"2026-01-29T10:43:39.388Z"}
|
||||
{"type":"task.created","taskId":"task_20260129_edqScT","status":"todo","id":"evt_GXTQ7VUbcadJ","timestamp":"2026-01-29T10:43:39.396Z"}
|
||||
{"type":"task.created","taskId":"task_20260129_DgWux_","status":"todo","id":"evt_kWR3LfiTNozj","timestamp":"2026-01-29T10:43:39.401Z"}
|
||||
{"type":"task.status_changed","taskId":"task_20260129_DgWux_","status":"in-progress","previousStatus":"todo","id":"evt_VKROv9KvQPES","timestamp":"2026-01-29T10:43:39.416Z"}
|
||||
{"type":"task.created","taskId":"task_20260129_Fq2Khr","status":"todo","id":"evt_LBoWJBOyA_eD","timestamp":"2026-01-29T10:43:39.423Z"}
|
||||
{"type":"task.created","taskId":"task_20260129_gXg6C_","status":"todo","id":"evt_3r96Oz3XrIsu","timestamp":"2026-01-29T10:43:39.434Z"}
|
||||
{"type":"task.created","taskId":"task_20260129_bT8IbG","status":"todo","id":"evt_f1bISSV3Lab5","timestamp":"2026-01-29T10:43:39.447Z"}
|
||||
{"type":"task.created","taskId":"task_20260129_KpqP8t","status":"todo","id":"evt_bCGPrOp2oLva","timestamp":"2026-01-29T10:43:39.489Z"}
|
||||
{"type":"task.archived","taskId":"task_20260129_KpqP8t","status":"todo","id":"evt_IG5fh-AhSTYu","timestamp":"2026-01-29T10:43:39.492Z"}
|
||||
{"type":"task.created","taskId":"task_20260129_Bk-w3M","status":"todo","id":"evt_FCBaLZ9Y_IU8","timestamp":"2026-01-29T10:43:39.495Z"}
|
||||
{"type":"task.created","taskId":"task_20260129_uiHu1c","status":"todo","id":"evt_y2WxAuujSWfk","timestamp":"2026-01-29T10:43:39.529Z"}
|
||||
{"type":"task.created","taskId":"task_20260129_gWsylX","status":"todo","id":"evt_Su_Ad0mpGdZG","timestamp":"2026-01-29T10:43:39.542Z"}
|
||||
{"type":"task.created","taskId":"task_20260129_zuK3XQ","status":"todo","id":"evt_he5Av8RDj2kH","timestamp":"2026-01-29T10:43:39.553Z"}
|
||||
{"type":"task.created","taskId":"task_20260129_PIX7Oj","status":"todo","id":"evt_XLfHl6-h46LO","timestamp":"2026-01-29T10:43:39.565Z"}
|
||||
{"type":"task.created","taskId":"task_20260129_i6gWtK","status":"todo","id":"evt_zCuJ5cTIP7tD","timestamp":"2026-01-29T10:43:39.598Z"}
|
||||
{"type":"task.created","taskId":"task_20260129_4dVU7f","status":"todo","id":"evt_d1kciWbGgX9U","timestamp":"2026-01-29T10:43:39.615Z"}
|
||||
{"type":"task.created","taskId":"task_20260129_j6zloq","status":"todo","id":"evt_Cp_qL7tJng9K","timestamp":"2026-01-29T10:43:39.632Z"}
|
||||
{"type":"task.created","taskId":"task_20260129_kLtUSz","status":"todo","id":"evt_8X0Ox4n7yrc8","timestamp":"2026-01-29T10:43:39.655Z"}
|
||||
{"type":"task.created","taskId":"task_20260129_eUbH4b","status":"todo","id":"evt_CJhCDVp95BO2","timestamp":"2026-01-29T10:43:39.701Z"}
|
||||
{"type":"task.status_changed","taskId":"task_20260126_legacy1","status":"blocked","previousStatus":"review","id":"evt_xC8qUT8wy93L","timestamp":"2026-01-29T10:43:39.798Z"}
|
||||
{"type":"task.created","taskId":"task_20260129_dbvTBb","status":"todo","id":"evt_farJ0EVW1hXC","timestamp":"2026-01-29T10:43:39.804Z"}
|
||||
{"type":"task.created","taskId":"task_20260129_wSrN88","status":"todo","id":"evt__LocL-E1bFv7","timestamp":"2026-01-29T10:43:39.814Z"}
|
||||
{"type":"task.status_changed","taskId":"task_20260129_wSrN88","status":"in-progress","previousStatus":"todo","id":"evt_hF2hNpcnFCzf","timestamp":"2026-01-29T10:43:39.815Z"}
|
||||
{"type":"task.created","taskId":"task_20260129_CLzx4Z","status":"todo","id":"evt_Mqy8cAW8Y4sb","timestamp":"2026-01-29T10:43:39.817Z"}
|
||||
{"type":"task.status_changed","taskId":"task_20260129_CLzx4Z","status":"blocked","previousStatus":"todo","id":"evt_C3ph1MYKOsM8","timestamp":"2026-01-29T10:43:39.818Z"}
|
||||
{"type":"task.created","taskId":"task_20260129_njMwWM","status":"todo","id":"evt_uihznv7cA_zK","timestamp":"2026-01-29T10:43:39.820Z"}
|
||||
{"type":"task.status_changed","taskId":"task_20260129_njMwWM","status":"done","previousStatus":"todo","id":"evt_X0wx_sWnGzN2","timestamp":"2026-01-29T10:43:39.821Z"}
|
||||
{"type":"task.status_changed","taskId":"task_20260126_idem1","status":"blocked","previousStatus":"review","id":"evt_vRI8hga4AtkJ","timestamp":"2026-01-29T10:43:39.841Z"}
|
||||
{"type":"task.status_changed","taskId":"task_20260126_multi1","status":"blocked","previousStatus":"review","id":"evt_Q2DyPBEur62o","timestamp":"2026-01-29T10:43:39.855Z"}
|
||||
{"type":"task.status_changed","taskId":"task_20260126_multi2","status":"blocked","previousStatus":"review","id":"evt_bn2VlK2DkQGc","timestamp":"2026-01-29T10:43:39.857Z"}
|
||||
{"type":"task.restored","taskId":"task_20260126_arch1","status":"done","id":"evt_JijWWBIypKx0","timestamp":"2026-01-29T10:43:39.921Z"}
|
||||
{"type":"task.status_changed","taskId":"task_20260126_arch1","status":"blocked","previousStatus":"done","id":"evt_XYvSj7aY9mH0","timestamp":"2026-01-29T10:43:39.939Z"}
|
||||
{"type":"task.archived","taskId":"task_20260126_arch1","status":"blocked","id":"evt_zpmB2KIudvo_","timestamp":"2026-01-29T10:43:39.948Z"}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,48 @@
|
|||
[
|
||||
{
|
||||
"id": "activity_1769683424525_3t53tke99",
|
||||
"type": "comment_added",
|
||||
"taskId": "task_20260129_0nTA6h",
|
||||
"taskTitle": "SECURITY: Implement per-route rate limiting with tiered thresholds",
|
||||
"details": {
|
||||
"author": "Veritas",
|
||||
"preview": "Implemented per-route tiered rate limiting. Added ..."
|
||||
},
|
||||
"timestamp": "2026-01-29T10:43:44.525Z"
|
||||
},
|
||||
{
|
||||
"id": "activity_1769683416842_pohnqciae",
|
||||
"type": "status_changed",
|
||||
"taskId": "task_20260129_0nTA6h",
|
||||
"taskTitle": "SECURITY: Implement per-route rate limiting with tiered thresholds",
|
||||
"details": {
|
||||
"from": "in-progress",
|
||||
"status": "done"
|
||||
},
|
||||
"timestamp": "2026-01-29T10:43:36.842Z"
|
||||
},
|
||||
{
|
||||
"id": "activity_1769683199842_q1mmyc0od",
|
||||
"type": "status_changed",
|
||||
"taskId": "task_20260129_0nTA6h",
|
||||
"taskTitle": "SECURITY: Implement per-route rate limiting with tiered thresholds",
|
||||
"details": {
|
||||
"from": "todo",
|
||||
"status": "in-progress"
|
||||
},
|
||||
"timestamp": "2026-01-29T10:39:59.842Z"
|
||||
},
|
||||
{
|
||||
"id": "activity_1769683199822_punrrdvec",
|
||||
"type": "status_changed",
|
||||
"taskId": "task_20260129_9nk1AM",
|
||||
"taskTitle": "STABILITY: Add WebSocket heartbeat, reconnection, and connection limits",
|
||||
"details": {
|
||||
"from": "todo",
|
||||
"status": "in-progress"
|
||||
},
|
||||
"timestamp": "2026-01-29T10:39:59.822Z"
|
||||
},
|
||||
{
|
||||
"id": "activity_1769683167629_q9635t20d",
|
||||
"type": "comment_added",
|
||||
|
|
|
|||
|
|
@ -1,4 +1,20 @@
|
|||
[
|
||||
{
|
||||
"id": "status_1769683344992_weqt52rrk",
|
||||
"timestamp": "2026-01-29T10:42:24.992Z",
|
||||
"previousStatus": "idle",
|
||||
"newStatus": "sub-agent",
|
||||
"subAgentCount": 2,
|
||||
"durationMs": 145141
|
||||
},
|
||||
{
|
||||
"id": "status_1769683199851_eec3jqh0e",
|
||||
"timestamp": "2026-01-29T10:39:59.851Z",
|
||||
"previousStatus": "idle",
|
||||
"newStatus": "sub-agent",
|
||||
"subAgentCount": 0,
|
||||
"durationMs": 334720
|
||||
},
|
||||
{
|
||||
"id": "status_1769682865131_7ror08cr9",
|
||||
"timestamp": "2026-01-29T10:34:25.131Z",
|
||||
|
|
|
|||
|
|
@ -1,11 +1,43 @@
|
|||
/**
|
||||
* Rate Limit Middleware Tests
|
||||
* Tests the rate limiting factory and pre-configured limiters.
|
||||
*
|
||||
* Tests the rate limiting factory, pre-configured tiered limiters,
|
||||
* Retry-After header on 429 responses, and route-level differentiation.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import express from 'express';
|
||||
import request from 'supertest';
|
||||
import { rateLimit, apiRateLimit, strictRateLimit } from '../../middleware/rate-limit.js';
|
||||
import {
|
||||
rateLimit,
|
||||
apiRateLimit,
|
||||
authRateLimit,
|
||||
writeRateLimit,
|
||||
readRateLimit,
|
||||
uploadRateLimit,
|
||||
strictRateLimit,
|
||||
} from '../../middleware/rate-limit.js';
|
||||
|
||||
// ── Helper ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Fire `count` GET requests and return the last response. */
|
||||
async function exhaust(app: express.Express, path: string, count: number) {
|
||||
let res: request.Response | undefined;
|
||||
for (let i = 0; i < count; i++) {
|
||||
res = await request(app).get(path);
|
||||
}
|
||||
return res!;
|
||||
}
|
||||
|
||||
/** Fire `count` POST requests and return the last response. */
|
||||
async function exhaustPost(app: express.Express, path: string, count: number) {
|
||||
let res: request.Response | undefined;
|
||||
for (let i = 0; i < count; i++) {
|
||||
res = await request(app).post(path).send({});
|
||||
}
|
||||
return res!;
|
||||
}
|
||||
|
||||
// ── Factory tests ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Rate Limit Middleware', () => {
|
||||
describe('rateLimit factory', () => {
|
||||
|
|
@ -37,10 +69,8 @@ describe('Rate Limit Middleware', () => {
|
|||
app.use(rateLimit({ limit: 2, windowMs: 10000 }));
|
||||
app.get('/', (_req, res) => res.json({ ok: true }));
|
||||
|
||||
// First 2 should succeed
|
||||
await request(app).get('/');
|
||||
await request(app).get('/');
|
||||
// Third should be rate limited
|
||||
const res = await request(app).get('/');
|
||||
expect(res.status).toBe(429);
|
||||
});
|
||||
|
|
@ -58,33 +88,50 @@ describe('Rate Limit Middleware', () => {
|
|||
|
||||
it('should support skip function', async () => {
|
||||
const app = express();
|
||||
app.use(rateLimit({
|
||||
limit: 1,
|
||||
windowMs: 10000,
|
||||
skip: (req) => req.path === '/health',
|
||||
}));
|
||||
app.use(
|
||||
rateLimit({
|
||||
limit: 1,
|
||||
windowMs: 10000,
|
||||
skip: (req) => req.path === '/health',
|
||||
})
|
||||
);
|
||||
app.get('/health', (_req, res) => res.json({ ok: true }));
|
||||
app.get('/api', (_req, res) => res.json({ ok: true }));
|
||||
|
||||
// Both should be skipped since skip returns true for /health
|
||||
await request(app).get('/health');
|
||||
const res = await request(app).get('/health');
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it('should include rate limit headers', async () => {
|
||||
it('should include rate limit headers on successful responses', async () => {
|
||||
const app = express();
|
||||
app.use(rateLimit({ limit: 10, windowMs: 60000 }));
|
||||
app.get('/', (_req, res) => res.json({ ok: true }));
|
||||
|
||||
const res = await request(app).get('/');
|
||||
// draft-7 uses combined RateLimit header; also check legacy X-RateLimit-* headers
|
||||
const hasStandard = 'ratelimit' in res.headers || 'ratelimit-limit' in res.headers;
|
||||
const hasLegacy = 'x-ratelimit-limit' in res.headers;
|
||||
expect(hasStandard || hasLegacy).toBe(true);
|
||||
});
|
||||
|
||||
it('should include Retry-After header on 429 responses', async () => {
|
||||
const app = express();
|
||||
app.use(rateLimit({ limit: 1, windowMs: 60000 }));
|
||||
app.get('/', (_req, res) => res.json({ ok: true }));
|
||||
|
||||
await request(app).get('/');
|
||||
const res = await request(app).get('/');
|
||||
expect(res.status).toBe(429);
|
||||
// express-rate-limit sets retry-after as seconds remaining
|
||||
expect(res.headers['retry-after']).toBeDefined();
|
||||
const retryAfter = Number(res.headers['retry-after']);
|
||||
expect(retryAfter).toBeGreaterThan(0);
|
||||
expect(retryAfter).toBeLessThanOrEqual(60);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Pre-configured limiter exports ─────────────────────────────────────────
|
||||
|
||||
describe('pre-configured limiters', () => {
|
||||
it('should export apiRateLimit', () => {
|
||||
expect(typeof apiRateLimit).toBe('function');
|
||||
|
|
@ -93,5 +140,312 @@ describe('Rate Limit Middleware', () => {
|
|||
it('should export strictRateLimit', () => {
|
||||
expect(typeof strictRateLimit).toBe('function');
|
||||
});
|
||||
|
||||
it('should export authRateLimit', () => {
|
||||
expect(typeof authRateLimit).toBe('function');
|
||||
});
|
||||
|
||||
it('should export writeRateLimit', () => {
|
||||
expect(typeof writeRateLimit).toBe('function');
|
||||
});
|
||||
|
||||
it('should export readRateLimit', () => {
|
||||
expect(typeof readRateLimit).toBe('function');
|
||||
});
|
||||
|
||||
it('should export uploadRateLimit', () => {
|
||||
expect(typeof uploadRateLimit).toBe('function');
|
||||
});
|
||||
});
|
||||
|
||||
// ── Tiered limit enforcement ───────────────────────────────────────────────
|
||||
|
||||
describe('authRateLimit (10 req / 15 min)', () => {
|
||||
it('should allow requests up to the limit', async () => {
|
||||
const app = express();
|
||||
app.use(authRateLimit);
|
||||
app.post('/login', (_req, res) => res.json({ ok: true }));
|
||||
|
||||
// First 10 requests should succeed
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const res = await request(app).post('/login').send({});
|
||||
expect(res.status).toBe(200);
|
||||
}
|
||||
});
|
||||
|
||||
it('should block the 11th request', async () => {
|
||||
const app = express();
|
||||
app.use(authRateLimit);
|
||||
app.post('/login', (_req, res) => res.json({ ok: true }));
|
||||
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await request(app).post('/login').send({});
|
||||
}
|
||||
|
||||
const res = await request(app).post('/login').send({});
|
||||
expect(res.status).toBe(429);
|
||||
expect(res.body.error).toContain('authentication');
|
||||
});
|
||||
|
||||
it('should include Retry-After header on 429', async () => {
|
||||
const app = express();
|
||||
app.use(authRateLimit);
|
||||
app.post('/login', (_req, res) => res.json({ ok: true }));
|
||||
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await request(app).post('/login').send({});
|
||||
}
|
||||
|
||||
const res = await request(app).post('/login').send({});
|
||||
expect(res.status).toBe(429);
|
||||
expect(res.headers['retry-after']).toBeDefined();
|
||||
const retryAfter = Number(res.headers['retry-after']);
|
||||
// Should be up to 15 minutes (900 seconds)
|
||||
expect(retryAfter).toBeGreaterThan(0);
|
||||
expect(retryAfter).toBeLessThanOrEqual(900);
|
||||
});
|
||||
});
|
||||
|
||||
describe('writeRateLimit (60 req / min)', () => {
|
||||
it('should allow requests up to the limit', async () => {
|
||||
const app = express();
|
||||
app.use(writeRateLimit);
|
||||
app.post('/items', (_req, res) => res.json({ ok: true }));
|
||||
|
||||
for (let i = 0; i < 60; i++) {
|
||||
const res = await request(app).post('/items').send({});
|
||||
expect(res.status).toBe(200);
|
||||
}
|
||||
});
|
||||
|
||||
it('should block the 61st request', async () => {
|
||||
const app = express();
|
||||
app.use(writeRateLimit);
|
||||
app.post('/items', (_req, res) => res.json({ ok: true }));
|
||||
|
||||
for (let i = 0; i < 60; i++) {
|
||||
await request(app).post('/items').send({});
|
||||
}
|
||||
|
||||
const res = await request(app).post('/items').send({});
|
||||
expect(res.status).toBe(429);
|
||||
expect(res.body.error).toContain('write');
|
||||
});
|
||||
|
||||
it('should include Retry-After header on 429', async () => {
|
||||
const app = express();
|
||||
app.use(writeRateLimit);
|
||||
app.post('/items', (_req, res) => res.json({ ok: true }));
|
||||
|
||||
for (let i = 0; i < 60; i++) {
|
||||
await request(app).post('/items').send({});
|
||||
}
|
||||
|
||||
const res = await request(app).post('/items').send({});
|
||||
expect(res.status).toBe(429);
|
||||
expect(res.headers['retry-after']).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('readRateLimit (300 req / min)', () => {
|
||||
it('should allow requests under the limit', async () => {
|
||||
const app = express();
|
||||
app.use(readRateLimit);
|
||||
app.get('/items', (_req, res) => res.json({ ok: true }));
|
||||
|
||||
// Just test a subset — 300 requests would be slow
|
||||
for (let i = 0; i < 50; i++) {
|
||||
const res = await request(app).get('/items');
|
||||
expect(res.status).toBe(200);
|
||||
}
|
||||
});
|
||||
|
||||
it('should return proper error message when limited', async () => {
|
||||
// Use a small-limit clone to test behavior without 300 requests
|
||||
const app = express();
|
||||
app.use(
|
||||
rateLimit({
|
||||
limit: 2,
|
||||
windowMs: 60000,
|
||||
message: 'Too many read requests. Please slow down.',
|
||||
})
|
||||
);
|
||||
app.get('/items', (_req, res) => res.json({ ok: true }));
|
||||
|
||||
await request(app).get('/items');
|
||||
await request(app).get('/items');
|
||||
const res = await request(app).get('/items');
|
||||
expect(res.status).toBe(429);
|
||||
expect(res.body.error).toContain('read');
|
||||
});
|
||||
});
|
||||
|
||||
describe('uploadRateLimit (20 req / min)', () => {
|
||||
it('should allow requests up to the limit', async () => {
|
||||
const app = express();
|
||||
app.use(uploadRateLimit);
|
||||
app.post('/upload', (_req, res) => res.json({ ok: true }));
|
||||
|
||||
for (let i = 0; i < 20; i++) {
|
||||
const res = await request(app).post('/upload').send({});
|
||||
expect(res.status).toBe(200);
|
||||
}
|
||||
});
|
||||
|
||||
it('should block the 21st request', async () => {
|
||||
const app = express();
|
||||
app.use(uploadRateLimit);
|
||||
app.post('/upload', (_req, res) => res.json({ ok: true }));
|
||||
|
||||
for (let i = 0; i < 20; i++) {
|
||||
await request(app).post('/upload').send({});
|
||||
}
|
||||
|
||||
const res = await request(app).post('/upload').send({});
|
||||
expect(res.status).toBe(429);
|
||||
expect(res.body.error).toContain('upload');
|
||||
});
|
||||
|
||||
it('should include Retry-After header on 429', async () => {
|
||||
const app = express();
|
||||
app.use(uploadRateLimit);
|
||||
app.post('/upload', (_req, res) => res.json({ ok: true }));
|
||||
|
||||
for (let i = 0; i < 20; i++) {
|
||||
await request(app).post('/upload').send({});
|
||||
}
|
||||
|
||||
const res = await request(app).post('/upload').send({});
|
||||
expect(res.status).toBe(429);
|
||||
expect(res.headers['retry-after']).toBeDefined();
|
||||
const retryAfter = Number(res.headers['retry-after']);
|
||||
expect(retryAfter).toBeGreaterThan(0);
|
||||
expect(retryAfter).toBeLessThanOrEqual(60);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Route-level differentiation ────────────────────────────────────────────
|
||||
|
||||
describe('different routes get different limits', () => {
|
||||
it('should apply stricter limit to auth vs general read', async () => {
|
||||
// Use fresh limiter instances to avoid cross-test MemoryStore bleed
|
||||
const freshAuth = rateLimit({ limit: 3, windowMs: 60000, message: 'Auth limited' });
|
||||
const freshRead = rateLimit({ limit: 20, windowMs: 60000, message: 'Read limited' });
|
||||
|
||||
const app = express();
|
||||
|
||||
// Auth routes
|
||||
app.use('/auth', freshAuth);
|
||||
app.post('/auth/login', (_req, res) => res.json({ ok: true }));
|
||||
|
||||
// Read routes
|
||||
app.use('/api', freshRead);
|
||||
app.get('/api/items', (_req, res) => res.json({ ok: true }));
|
||||
|
||||
// Exhaust auth limit (3 requests)
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await request(app).post('/auth/login').send({});
|
||||
}
|
||||
// Auth should now be blocked
|
||||
const authRes = await request(app).post('/auth/login').send({});
|
||||
expect(authRes.status).toBe(429);
|
||||
|
||||
// Read should still work fine (separate limiter, only used 0 of its 20 limit)
|
||||
const readRes = await request(app).get('/api/items');
|
||||
expect(readRes.status).toBe(200);
|
||||
});
|
||||
|
||||
it('should apply stricter limit to uploads vs general writes', async () => {
|
||||
// Use fresh limiter instances to avoid cross-test MemoryStore bleed
|
||||
const freshUpload = rateLimit({ limit: 3, windowMs: 60000, message: 'Upload limited' });
|
||||
const freshWrite = rateLimit({ limit: 10, windowMs: 60000, message: 'Write limited' });
|
||||
|
||||
const app = express();
|
||||
|
||||
app.use('/upload', freshUpload);
|
||||
app.post('/upload/file', (_req, res) => res.json({ ok: true }));
|
||||
|
||||
app.use('/write', freshWrite);
|
||||
app.post('/write/item', (_req, res) => res.json({ ok: true }));
|
||||
|
||||
// Exhaust upload limit (3 requests)
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await request(app).post('/upload/file').send({});
|
||||
}
|
||||
// Upload should be blocked
|
||||
const uploadRes = await request(app).post('/upload/file').send({});
|
||||
expect(uploadRes.status).toBe(429);
|
||||
|
||||
// Write should still work (separate limiter, only used 0 of its 10 limit)
|
||||
const writeRes = await request(app).post('/write/item').send({});
|
||||
expect(writeRes.status).toBe(200);
|
||||
});
|
||||
|
||||
it('should enforce write limit independent of read limit', async () => {
|
||||
const app = express();
|
||||
|
||||
// Use small limits for fast testing
|
||||
const testWrite = rateLimit({ limit: 3, windowMs: 60000, message: 'Write limited' });
|
||||
const testRead = rateLimit({ limit: 5, windowMs: 60000, message: 'Read limited' });
|
||||
|
||||
app.post('/items', testWrite, (_req, res) => res.json({ ok: true }));
|
||||
app.get('/items', testRead, (_req, res) => res.json({ ok: true }));
|
||||
|
||||
// Exhaust write limit
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await request(app).post('/items').send({});
|
||||
}
|
||||
const writeRes = await request(app).post('/items').send({});
|
||||
expect(writeRes.status).toBe(429);
|
||||
|
||||
// Read should still work (separate limiter instance)
|
||||
const readRes = await request(app).get('/items');
|
||||
expect(readRes.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
// ── 429 response format ────────────────────────────────────────────────────
|
||||
|
||||
describe('429 response format', () => {
|
||||
it('should return JSON error body', async () => {
|
||||
const app = express();
|
||||
app.use(rateLimit({ limit: 1, windowMs: 60000 }));
|
||||
app.get('/', (_req, res) => res.json({ ok: true }));
|
||||
|
||||
await request(app).get('/');
|
||||
const res = await request(app).get('/');
|
||||
|
||||
expect(res.status).toBe(429);
|
||||
expect(res.body).toHaveProperty('error');
|
||||
expect(typeof res.body.error).toBe('string');
|
||||
});
|
||||
|
||||
it('should set Content-Type to application/json on 429', async () => {
|
||||
const app = express();
|
||||
app.use(rateLimit({ limit: 1, windowMs: 60000 }));
|
||||
app.get('/', (_req, res) => res.json({ ok: true }));
|
||||
|
||||
await request(app).get('/');
|
||||
const res = await request(app).get('/');
|
||||
|
||||
expect(res.status).toBe(429);
|
||||
expect(res.headers['content-type']).toMatch(/application\/json/);
|
||||
});
|
||||
|
||||
it('should include Retry-After as integer seconds', async () => {
|
||||
const app = express();
|
||||
app.use(rateLimit({ limit: 1, windowMs: 30000 }));
|
||||
app.get('/', (_req, res) => res.json({ ok: true }));
|
||||
|
||||
await request(app).get('/');
|
||||
const res = await request(app).get('/');
|
||||
|
||||
expect(res.status).toBe(429);
|
||||
const retryAfter = res.headers['retry-after'];
|
||||
expect(retryAfter).toBeDefined();
|
||||
// Should be a numeric string representing seconds
|
||||
expect(Number(retryAfter)).toBeGreaterThan(0);
|
||||
expect(Number(retryAfter)).toBeLessThanOrEqual(30);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ import authRoutes from './routes/auth.js';
|
|||
import { checkJwtSecretConfig } from './config/security.js';
|
||||
import swaggerUi from 'swagger-ui-express';
|
||||
import { swaggerSpec } from './config/swagger.js';
|
||||
import { apiRateLimit } from './middleware/rate-limit.js';
|
||||
import { apiRateLimit, authRateLimit } from './middleware/rate-limit.js';
|
||||
import { apiVersionMiddleware } from './middleware/api-version.js';
|
||||
import { apiCacheHeaders } from './middleware/cache-control.js';
|
||||
import type { AgentOutput } from './services/clawdbot-agent-service.js';
|
||||
|
|
@ -322,9 +322,10 @@ app.get('/api/v1/auth/diagnostics', authenticate, authorize('admin'), (_req, res
|
|||
// ============================================
|
||||
// Auth Routes (unauthenticated - for login/setup)
|
||||
// Available at both /api/auth and /api/v1/auth
|
||||
// Auth rate limit: 10 req / 15 min (very strict)
|
||||
// ============================================
|
||||
app.use('/api/v1/auth', authRoutes);
|
||||
app.use('/api/auth', authRoutes);
|
||||
app.use('/api/v1/auth', authRateLimit, authRoutes);
|
||||
app.use('/api/auth', authRateLimit, authRoutes);
|
||||
|
||||
// ============================================
|
||||
// Security: Rate Limiting (100 req/min)
|
||||
|
|
@ -429,9 +430,25 @@ let configService: ConfigService | null = null;
|
|||
// Create HTTP server
|
||||
const server = createServer(app);
|
||||
|
||||
// WebSocket server for real-time updates
|
||||
// ============================================
|
||||
// WebSocket Server — Real-time Updates
|
||||
// ============================================
|
||||
// verifyClient validates the Origin header BEFORE the upgrade handshake completes,
|
||||
// blocking cross-site WebSocket hijacking (CSWSH) from malicious pages.
|
||||
|
||||
/** Maximum concurrent WebSocket connections. New connections are rejected with 1013 when at capacity. */
|
||||
const WS_MAX_CONNECTIONS = 50;
|
||||
/** Interval between server→client ping frames (ms). */
|
||||
const WS_HEARTBEAT_INTERVAL_MS = 30_000;
|
||||
/** Time after ping to wait for pong before terminating the connection (ms). */
|
||||
const WS_PONG_TIMEOUT_MS = 10_000;
|
||||
|
||||
/** Extended WebSocket with heartbeat tracking. */
|
||||
interface HeartbeatWebSocket extends AuthenticatedWebSocket {
|
||||
isAlive?: boolean;
|
||||
heartbeatTimer?: ReturnType<typeof setTimeout>;
|
||||
}
|
||||
|
||||
const wss = new WebSocketServer({
|
||||
server,
|
||||
path: '/ws',
|
||||
|
|
@ -461,7 +478,45 @@ setHealthWss(wss);
|
|||
// Track subscriptions: taskId -> Set of WebSocket clients
|
||||
const agentSubscriptions = new Map<string, Set<WebSocket>>();
|
||||
|
||||
wss.on('connection', (ws: AuthenticatedWebSocket, req) => {
|
||||
// ---- Heartbeat: server pings every WS_HEARTBEAT_INTERVAL_MS ----
|
||||
const heartbeatInterval = setInterval(() => {
|
||||
for (const client of wss.clients) {
|
||||
const hbClient = client as HeartbeatWebSocket;
|
||||
if (hbClient.isAlive === false) {
|
||||
// No pong received since last ping — terminate
|
||||
log.warn('WebSocket client failed heartbeat — terminating');
|
||||
hbClient.terminate();
|
||||
continue;
|
||||
}
|
||||
// Mark as waiting-for-pong, then send ping
|
||||
hbClient.isAlive = false;
|
||||
hbClient.ping();
|
||||
// Safety net: if pong doesn't arrive within WS_PONG_TIMEOUT_MS, terminate
|
||||
hbClient.heartbeatTimer = setTimeout(() => {
|
||||
if (hbClient.isAlive === false && hbClient.readyState === WebSocket.OPEN) {
|
||||
log.warn('WebSocket client pong timeout — terminating');
|
||||
hbClient.terminate();
|
||||
}
|
||||
}, WS_PONG_TIMEOUT_MS);
|
||||
}
|
||||
}, WS_HEARTBEAT_INTERVAL_MS);
|
||||
|
||||
// Stop heartbeat when the WSS itself closes
|
||||
wss.on('close', () => {
|
||||
clearInterval(heartbeatInterval);
|
||||
});
|
||||
|
||||
wss.on('connection', (ws: HeartbeatWebSocket, req) => {
|
||||
// ---- Connection limit enforcement ----
|
||||
if (wss.clients.size > WS_MAX_CONNECTIONS) {
|
||||
log.warn(
|
||||
{ current: wss.clients.size, max: WS_MAX_CONNECTIONS },
|
||||
'WebSocket connection limit reached — rejecting'
|
||||
);
|
||||
ws.close(1013, 'Try again later');
|
||||
return;
|
||||
}
|
||||
|
||||
// Authenticate WebSocket connection
|
||||
const authResult = authenticateWebSocket(req);
|
||||
|
||||
|
|
@ -478,8 +533,18 @@ wss.on('connection', (ws: AuthenticatedWebSocket, req) => {
|
|||
isLocalhost: authResult.isLocalhost,
|
||||
};
|
||||
|
||||
// ---- Heartbeat: mark alive on connect and on pong ----
|
||||
ws.isAlive = true;
|
||||
ws.on('pong', () => {
|
||||
ws.isAlive = true;
|
||||
if (ws.heartbeatTimer) {
|
||||
clearTimeout(ws.heartbeatTimer);
|
||||
ws.heartbeatTimer = undefined;
|
||||
}
|
||||
});
|
||||
|
||||
log.info(
|
||||
{ role: authResult.role, localhost: authResult.isLocalhost },
|
||||
{ role: authResult.role, localhost: authResult.isLocalhost, clients: wss.clients.size },
|
||||
'WebSocket client connected'
|
||||
);
|
||||
|
||||
|
|
@ -584,7 +649,13 @@ wss.on('connection', (ws: AuthenticatedWebSocket, req) => {
|
|||
});
|
||||
|
||||
ws.on('close', () => {
|
||||
log.info('WebSocket client disconnected');
|
||||
log.info({ clients: wss.clients.size }, 'WebSocket client disconnected');
|
||||
|
||||
// Clean up heartbeat timer
|
||||
if (ws.heartbeatTimer) {
|
||||
clearTimeout(ws.heartbeatTimer);
|
||||
ws.heartbeatTimer = undefined;
|
||||
}
|
||||
|
||||
// Clean up subscriptions
|
||||
if (subscribedTaskId) {
|
||||
|
|
@ -606,10 +677,16 @@ export { wss };
|
|||
async function gracefulShutdown(signal: string) {
|
||||
log.info({ signal }, 'Shutting down gracefully');
|
||||
|
||||
// 1. Close WebSocket connections first (stop accepting new messages)
|
||||
// 1. Stop heartbeat interval and close WebSocket connections
|
||||
clearInterval(heartbeatInterval);
|
||||
log.info({ clients: wss.clients.size }, 'Closing WebSocket connections');
|
||||
wss.clients.forEach((client) => {
|
||||
client.close(1000, 'Server shutting down');
|
||||
const hbClient = client as HeartbeatWebSocket;
|
||||
if (hbClient.heartbeatTimer) {
|
||||
clearTimeout(hbClient.heartbeatTimer);
|
||||
hbClient.heartbeatTimer = undefined;
|
||||
}
|
||||
client.close(1001, 'Server going away');
|
||||
});
|
||||
|
||||
// Close the WebSocket server itself (stop accepting new connections)
|
||||
|
|
|
|||
|
|
@ -12,8 +12,12 @@ import type { Request } from 'express';
|
|||
* State resets on server restart, which is acceptable for this use case.
|
||||
* If persistence is ever needed, swap MemoryStore for a file or Redis store.
|
||||
*
|
||||
* Defaults are tuned for a local dev tool accessed by a single user + AI agent.
|
||||
* Override via RATE_LIMIT_MAX env var (requests per minute).
|
||||
* Tiered rate limits:
|
||||
* - authRateLimit — 10 req / 15 min (login, token refresh)
|
||||
* - uploadRateLimit — 20 req / min (file uploads)
|
||||
* - writeRateLimit — 60 req / min (POST, PUT, PATCH, DELETE)
|
||||
* - readRateLimit — 300 req / min (GET requests)
|
||||
* - apiRateLimit — 300 req / min (global fallback, localhost exempt)
|
||||
*/
|
||||
|
||||
// ── Configuration ──────────────────────────────────────────────────────────────
|
||||
|
|
@ -31,9 +35,6 @@ const API_LIMIT: number = (() => {
|
|||
return DEFAULT_API_LIMIT;
|
||||
})();
|
||||
|
||||
/** Strict limit for sensitive endpoints (auth, settings). */
|
||||
const STRICT_LIMIT = 15;
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Returns true when the request originates from localhost / loopback. */
|
||||
|
|
@ -47,6 +48,8 @@ function isLocalhost(req: Request): boolean {
|
|||
/**
|
||||
* Create a rate limiting middleware with the given options.
|
||||
* Wraps express-rate-limit for consistency.
|
||||
*
|
||||
* All limiters return proper 429 responses with Retry-After header.
|
||||
*/
|
||||
export function rateLimit(
|
||||
options: {
|
||||
|
|
@ -77,7 +80,7 @@ export function rateLimit(
|
|||
// ── Pre-configured limiters ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Pre-configured rate limiter for general API use.
|
||||
* Pre-configured rate limiter for general API use (global fallback).
|
||||
* Default: 300 req/min per IP (override with RATE_LIMIT_MAX env var).
|
||||
* Localhost requests are exempt — this is a local dev tool.
|
||||
*/
|
||||
|
|
@ -89,12 +92,57 @@ export const apiRateLimit = rateLimit({
|
|||
});
|
||||
|
||||
/**
|
||||
* Stricter rate limiter for sensitive operations (15 req/min per IP).
|
||||
* Applied to: auth endpoints, settings mutations.
|
||||
* Very strict rate limiter for auth operations: 10 req / 15 min per IP.
|
||||
* Applied to: login, token refresh, password setup endpoints.
|
||||
* Localhost is NOT exempt — protects against runaway scripts.
|
||||
*/
|
||||
export const strictRateLimit = rateLimit({
|
||||
limit: STRICT_LIMIT,
|
||||
windowMs: 60_000,
|
||||
message: `Too many requests. Max ${STRICT_LIMIT} per minute for this endpoint.`,
|
||||
export const authRateLimit = rateLimit({
|
||||
limit: 10,
|
||||
windowMs: 15 * 60_000, // 15 minutes
|
||||
message: 'Too many authentication attempts. Please try again later.',
|
||||
});
|
||||
|
||||
/**
|
||||
* Moderate rate limiter for write operations: 60 req / min per IP.
|
||||
* Applied to: POST, PUT, PATCH, DELETE on resource endpoints.
|
||||
* Localhost is NOT exempt — protects against runaway scripts.
|
||||
*/
|
||||
export const writeRateLimit = rateLimit({
|
||||
limit: 60,
|
||||
windowMs: 60_000,
|
||||
message: 'Too many write requests. Please slow down.',
|
||||
});
|
||||
|
||||
/**
|
||||
* Generous rate limiter for read operations: 300 req / min per IP.
|
||||
* Applied to: GET requests on resource endpoints.
|
||||
* Localhost is NOT exempt — consistent with other tiered limiters.
|
||||
*/
|
||||
export const readRateLimit = rateLimit({
|
||||
limit: 300,
|
||||
windowMs: 60_000,
|
||||
message: 'Too many read requests. Please slow down.',
|
||||
});
|
||||
|
||||
/**
|
||||
* Strict rate limiter for file uploads: 20 req / min per IP.
|
||||
* Applied to: attachment/upload endpoints.
|
||||
* Localhost is NOT exempt — protects against disk exhaustion.
|
||||
*/
|
||||
export const uploadRateLimit = rateLimit({
|
||||
limit: 20,
|
||||
windowMs: 60_000,
|
||||
message: 'Too many upload requests. Please slow down.',
|
||||
});
|
||||
|
||||
/**
|
||||
* Stricter rate limiter for sensitive operations (15 req/min per IP).
|
||||
* Applied to: settings mutations.
|
||||
* Localhost is NOT exempt — protects against runaway scripts.
|
||||
* @deprecated Use the specific tiered limiters (authRateLimit, writeRateLimit, etc.) instead.
|
||||
*/
|
||||
export const strictRateLimit = rateLimit({
|
||||
limit: 15,
|
||||
windowMs: 60_000,
|
||||
message: 'Too many requests. Max 15 per minute for this endpoint.',
|
||||
});
|
||||
|
|
|
|||
|
|
@ -7,8 +7,15 @@
|
|||
* Route ordering matters:
|
||||
* - Archive and time routes MUST come before main taskRoutes so that
|
||||
* /archived and /time/summary are matched before the /:id param.
|
||||
*
|
||||
* Rate limiting tiers (applied per-route):
|
||||
* - readRateLimit — 300 req/min (GET endpoints)
|
||||
* - writeRateLimit — 60 req/min (POST/PUT/PATCH/DELETE)
|
||||
* - uploadRateLimit — 20 req/min (file upload endpoints)
|
||||
* Global apiRateLimit (300 req/min, localhost exempt) is applied upstream in index.ts.
|
||||
*/
|
||||
import { Router, type IRouter } from 'express';
|
||||
import { Router, type IRouter, type Request } from 'express';
|
||||
import { readRateLimit, writeRateLimit, uploadRateLimit } from '../../middleware/rate-limit.js';
|
||||
|
||||
// Task routes (order-sensitive — see note above)
|
||||
import { taskArchiveRoutes } from '../task-archive.js';
|
||||
|
|
@ -43,13 +50,37 @@ import digestRoutes from '../digest.js';
|
|||
|
||||
const v1Router: IRouter = Router();
|
||||
|
||||
// ── Tiered rate limiting by HTTP method ──────────────────────
|
||||
// GET → readRateLimit (300 req/min)
|
||||
// POST/PUT/PATCH/DELETE → writeRateLimit (60 req/min)
|
||||
// The global apiRateLimit (applied upstream) acts as an outer cap.
|
||||
v1Router.use((req: Request, _res, next) => {
|
||||
if (req.method === 'GET' || req.method === 'HEAD') {
|
||||
return readRateLimit(req, _res, next);
|
||||
}
|
||||
return writeRateLimit(req, _res, next);
|
||||
});
|
||||
|
||||
// ── Task routes (order-sensitive) ────────────────────────────
|
||||
v1Router.use('/tasks', taskArchiveRoutes);
|
||||
v1Router.use('/tasks', taskTimeRoutes);
|
||||
v1Router.use('/tasks', taskRoutes);
|
||||
v1Router.use('/tasks', taskCommentRoutes);
|
||||
v1Router.use('/tasks', taskSubtaskRoutes);
|
||||
v1Router.use('/tasks', attachmentRoutes);
|
||||
|
||||
// Attachment routes get the stricter upload rate limit (20 req/min)
|
||||
// applied BEFORE the route handler for upload (POST) requests.
|
||||
v1Router.use(
|
||||
'/tasks',
|
||||
(req: Request, _res, next) => {
|
||||
// Only apply upload limit to POST on attachment paths
|
||||
if (req.method === 'POST' && req.path.match(/\/[^/]+\/attachments/)) {
|
||||
return uploadRateLimit(req, _res, next);
|
||||
}
|
||||
next();
|
||||
},
|
||||
attachmentRoutes
|
||||
);
|
||||
|
||||
// ── Feature routes ───────────────────────────────────────────
|
||||
v1Router.use('/config', configRoutes);
|
||||
|
|
|
|||
|
|
@ -14,10 +14,14 @@ import { ErrorBoundary } from './components/shared/ErrorBoundary';
|
|||
// Main app content (only rendered when authenticated)
|
||||
function AppContent() {
|
||||
// Connect to WebSocket for real-time task updates
|
||||
const { isConnected } = useTaskSync();
|
||||
const { isConnected, connectionState, reconnectAttempt } = useTaskSync();
|
||||
|
||||
return (
|
||||
<WebSocketStatusProvider isConnected={isConnected}>
|
||||
<WebSocketStatusProvider
|
||||
isConnected={isConnected}
|
||||
connectionState={connectionState}
|
||||
reconnectAttempt={reconnectAttempt}
|
||||
>
|
||||
<KeyboardProvider>
|
||||
<BulkActionsProvider>
|
||||
<TaskConfigProvider>
|
||||
|
|
|
|||
|
|
@ -1,55 +1,76 @@
|
|||
import { useWebSocketStatus } from '@/contexts/WebSocketContext';
|
||||
import { Wifi, WifiOff } from 'lucide-react';
|
||||
import { Wifi, WifiOff, RefreshCw } from 'lucide-react';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
|
||||
/**
|
||||
* 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)
|
||||
* Green dot + wifi icon = connected (real-time updates active)
|
||||
* Yellow dot + spinner = reconnecting (trying to restore connection)
|
||||
* Red dot + wifi-off = disconnected (gave up or not started)
|
||||
*
|
||||
* Click to see a brief explanation of what it means.
|
||||
*/
|
||||
export function WebSocketIndicator() {
|
||||
const { isConnected } = useWebSocketStatus();
|
||||
const { connectionState, reconnectAttempt } = useWebSocketStatus();
|
||||
|
||||
const isConnected = connectionState === 'connected';
|
||||
const isReconnecting = connectionState === 'reconnecting' || connectionState === 'connecting';
|
||||
|
||||
// Dot color
|
||||
const dotClass = isConnected
|
||||
? 'bg-green-500 shadow-[0_0_4px_rgba(34,197,94,0.5)]'
|
||||
: isReconnecting
|
||||
? 'bg-yellow-500 shadow-[0_0_4px_rgba(234,179,8,0.5)] animate-pulse'
|
||||
: 'bg-red-500 shadow-[0_0_4px_rgba(239,68,68,0.4)]';
|
||||
|
||||
// Icon
|
||||
const Icon = isConnected ? Wifi : isReconnecting ? RefreshCw : WifiOff;
|
||||
const iconColor = isConnected
|
||||
? 'text-green-500'
|
||||
: isReconnecting
|
||||
? 'text-yellow-500'
|
||||
: 'text-red-500';
|
||||
const iconClass = isReconnecting ? `${iconColor} animate-spin` : iconColor;
|
||||
|
||||
// Label
|
||||
const label = isConnected
|
||||
? 'WebSocket connected'
|
||||
: isReconnecting
|
||||
? `WebSocket reconnecting (attempt ${reconnectAttempt})`
|
||||
: 'WebSocket disconnected';
|
||||
|
||||
// Popover heading
|
||||
const heading = isConnected
|
||||
? 'Real-time sync active'
|
||||
: isReconnecting
|
||||
? 'Reconnecting…'
|
||||
: 'Connection lost';
|
||||
|
||||
// Popover body
|
||||
const body = isConnected
|
||||
? 'Board updates are delivered instantly via WebSocket. Changes from agents and other tabs appear in real time.'
|
||||
: isReconnecting
|
||||
? `Attempting to restore the WebSocket connection (attempt ${reconnectAttempt}). The board is polling the server for updates in the meantime.`
|
||||
: 'Could not establish a WebSocket connection after multiple attempts. The board is polling the server every 10 seconds for updates. Refresh the page to try again.';
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
className="flex items-center gap-1 text-xs text-muted-foreground cursor-pointer select-none rounded px-1.5 py-1 transition-colors hover:bg-muted/50 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
aria-label={isConnected ? 'WebSocket connected' : 'WebSocket disconnected'}
|
||||
aria-label={label}
|
||||
>
|
||||
<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)]'
|
||||
}`}
|
||||
/>
|
||||
{isConnected ? (
|
||||
<Wifi className="h-3 w-3 text-green-500" />
|
||||
) : (
|
||||
<WifiOff className="h-3 w-3 text-red-500" />
|
||||
)}
|
||||
<span className={`inline-block h-2 w-2 rounded-full ${dotClass}`} />
|
||||
<Icon className={`h-3 w-3 ${iconClass}`} />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent side="bottom" align="end" className="w-64 p-3">
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
{isConnected ? (
|
||||
<Wifi className="h-4 w-4 text-green-500 shrink-0" />
|
||||
) : (
|
||||
<WifiOff className="h-4 w-4 text-red-500 shrink-0" />
|
||||
)}
|
||||
<span className="text-sm font-medium">
|
||||
{isConnected ? 'Real-time sync active' : 'Disconnected'}
|
||||
</span>
|
||||
<Icon className={`h-4 w-4 ${iconColor} shrink-0`} />
|
||||
<span className="text-sm font-medium">{heading}</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
{isConnected
|
||||
? 'Board updates are delivered instantly via WebSocket. Changes from agents and other tabs appear in real time.'
|
||||
: 'WebSocket connection lost. The board is polling the server every 10 seconds for updates. Reconnecting automatically…'}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">{body}</p>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
|
|
|||
|
|
@ -1,23 +1,34 @@
|
|||
import { createContext, useContext, type ReactNode } from 'react';
|
||||
import type { ConnectionState } from '@/hooks/useWebSocket';
|
||||
|
||||
interface WebSocketStatus {
|
||||
/** Whether the WebSocket is currently connected */
|
||||
isConnected: boolean;
|
||||
/** Detailed connection state */
|
||||
connectionState: ConnectionState;
|
||||
/** Current reconnect attempt (0 when connected or idle) */
|
||||
reconnectAttempt: number;
|
||||
}
|
||||
|
||||
const WebSocketStatusContext = createContext<WebSocketStatus>({
|
||||
isConnected: false,
|
||||
connectionState: 'disconnected',
|
||||
reconnectAttempt: 0,
|
||||
});
|
||||
|
||||
export function WebSocketStatusProvider({
|
||||
children,
|
||||
isConnected,
|
||||
connectionState,
|
||||
reconnectAttempt,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
isConnected: boolean;
|
||||
connectionState: ConnectionState;
|
||||
reconnectAttempt: number;
|
||||
}) {
|
||||
return (
|
||||
<WebSocketStatusContext.Provider value={{ isConnected }}>
|
||||
<WebSocketStatusContext.Provider value={{ isConnected, connectionState, reconnectAttempt }}>
|
||||
{children}
|
||||
</WebSocketStatusContext.Provider>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ export function useAgentStatus(taskId: string | undefined) {
|
|||
queryKey: ['agent', 'status', taskId],
|
||||
queryFn: () => api.agent.status(taskId!),
|
||||
enabled: !!taskId,
|
||||
refetchInterval: (query) => query.state.data?.running ? 2000 : false,
|
||||
refetchInterval: (query) => (query.state.data?.running ? 2000 : false),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -67,24 +67,30 @@ export function useAgentStream(taskId: string | undefined) {
|
|||
const [isRunning, setIsRunning] = useState(false);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const handleMessage = useCallback((message: WebSocketMessage) => {
|
||||
if (message.type === 'subscribed') {
|
||||
setIsRunning(message.running as boolean);
|
||||
} else if (message.type === 'agent:output') {
|
||||
setOutputs(prev => [...prev, {
|
||||
type: message.outputType as AgentOutput['type'],
|
||||
content: message.content as string,
|
||||
timestamp: message.timestamp as string,
|
||||
}]);
|
||||
} else if (message.type === 'agent:complete') {
|
||||
setIsRunning(false);
|
||||
queryClient.invalidateQueries({ queryKey: ['agent', 'status', taskId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['tasks'] });
|
||||
} else if (message.type === 'agent:error') {
|
||||
setIsRunning(false);
|
||||
queryClient.invalidateQueries({ queryKey: ['agent', 'status', taskId] });
|
||||
}
|
||||
}, [taskId, queryClient]);
|
||||
const handleMessage = useCallback(
|
||||
(message: WebSocketMessage) => {
|
||||
if (message.type === 'subscribed') {
|
||||
setIsRunning(message.running as boolean);
|
||||
} else if (message.type === 'agent:output') {
|
||||
setOutputs((prev) => [
|
||||
...prev,
|
||||
{
|
||||
type: message.outputType as AgentOutput['type'],
|
||||
content: message.content as string,
|
||||
timestamp: message.timestamp as string,
|
||||
},
|
||||
]);
|
||||
} else if (message.type === 'agent:complete') {
|
||||
setIsRunning(false);
|
||||
queryClient.invalidateQueries({ queryKey: ['agent', 'status', taskId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['tasks'] });
|
||||
} else if (message.type === 'agent:error') {
|
||||
setIsRunning(false);
|
||||
queryClient.invalidateQueries({ queryKey: ['agent', 'status', taskId] });
|
||||
}
|
||||
},
|
||||
[taskId, queryClient]
|
||||
);
|
||||
|
||||
// Clear outputs when taskId changes
|
||||
useEffect(() => {
|
||||
|
|
@ -95,7 +101,7 @@ export function useAgentStream(taskId: string | undefined) {
|
|||
autoConnect: !!taskId,
|
||||
onOpen: taskId ? { type: 'subscribe', taskId } : undefined,
|
||||
onMessage: handleMessage,
|
||||
reconnectDelay: 0, // Don't reconnect for agent streams
|
||||
autoReconnect: false, // Don't auto-reconnect for agent streams
|
||||
});
|
||||
|
||||
const clearOutputs = useCallback(() => {
|
||||
|
|
|
|||
|
|
@ -58,20 +58,20 @@ function isAgentStatusMessage(msg: WebSocketMessage): msg is AgentStatusWebSocke
|
|||
|
||||
/**
|
||||
* Hook to subscribe to real-time global agent status updates.
|
||||
*
|
||||
*
|
||||
* Uses WebSocket as primary transport with automatic fallback to polling
|
||||
* when WebSocket is disconnected. Detects stale status (no update in 5+ min).
|
||||
*
|
||||
*
|
||||
* Note: For per-task agent status, use `useAgentStatus(taskId)` from `useAgent.ts`.
|
||||
*
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* const { status, activeTask, subAgents, lastUpdated, isStale } = useRealtimeAgentStatus();
|
||||
*
|
||||
*
|
||||
* if (isStale) {
|
||||
* return <span>Agent idle</span>;
|
||||
* }
|
||||
*
|
||||
*
|
||||
* return <span>{status}</span>;
|
||||
* ```
|
||||
*/
|
||||
|
|
@ -82,7 +82,7 @@ export function useRealtimeAgentStatus(): AgentStatusData {
|
|||
subAgents: [],
|
||||
lastUpdated: new Date().toISOString(),
|
||||
});
|
||||
|
||||
|
||||
const [isStale, setIsStale] = useState(false);
|
||||
const pollIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const staleCheckRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
|
@ -91,7 +91,7 @@ export function useRealtimeAgentStatus(): AgentStatusData {
|
|||
// Handle incoming WebSocket messages
|
||||
const handleMessage = useCallback((message: WebSocketMessage) => {
|
||||
if (!mountedRef.current) return;
|
||||
|
||||
|
||||
if (isAgentStatusMessage(message)) {
|
||||
setStatusData({
|
||||
status: message.status,
|
||||
|
|
@ -109,11 +109,11 @@ export function useRealtimeAgentStatus(): AgentStatusData {
|
|||
// Fetch status via REST API (polling fallback)
|
||||
const fetchStatus = useCallback(async () => {
|
||||
if (!mountedRef.current) return;
|
||||
|
||||
|
||||
try {
|
||||
const data: GlobalAgentStatus = await api.agent.globalStatus();
|
||||
if (!mountedRef.current) return;
|
||||
|
||||
|
||||
setStatusData({
|
||||
status: data.status,
|
||||
activeTask: data.activeTask,
|
||||
|
|
@ -133,10 +133,10 @@ export function useRealtimeAgentStatus(): AgentStatusData {
|
|||
// Start polling when WebSocket disconnects
|
||||
const startPolling = useCallback(() => {
|
||||
if (pollIntervalRef.current) return;
|
||||
|
||||
|
||||
// Fetch immediately
|
||||
fetchStatus();
|
||||
|
||||
|
||||
// Then poll at interval
|
||||
pollIntervalRef.current = setInterval(fetchStatus, POLL_INTERVAL_MS);
|
||||
}, [fetchStatus]);
|
||||
|
|
@ -152,11 +152,11 @@ export function useRealtimeAgentStatus(): AgentStatusData {
|
|||
// Check for stale status
|
||||
const checkStale = useCallback(() => {
|
||||
if (!mountedRef.current) return;
|
||||
|
||||
|
||||
const lastUpdated = new Date(statusData.lastUpdated).getTime();
|
||||
const now = Date.now();
|
||||
const isNowStale = now - lastUpdated > STALE_THRESHOLD_MS;
|
||||
|
||||
|
||||
setIsStale(isNowStale);
|
||||
}, [statusData.lastUpdated]);
|
||||
|
||||
|
|
@ -173,16 +173,16 @@ export function useRealtimeAgentStatus(): AgentStatusData {
|
|||
onDisconnected: () => {
|
||||
startPolling();
|
||||
},
|
||||
reconnectDelay: 3000,
|
||||
// Uses default exponential backoff (1s, 2s, 4s, … max 30s)
|
||||
});
|
||||
|
||||
// Setup stale check interval
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
|
||||
|
||||
// Initial stale check
|
||||
checkStale();
|
||||
|
||||
|
||||
// Periodic stale checks
|
||||
staleCheckRef.current = setInterval(checkStale, STALE_CHECK_INTERVAL_MS);
|
||||
|
||||
|
|
@ -202,11 +202,14 @@ export function useRealtimeAgentStatus(): AgentStatusData {
|
|||
}, [fetchStatus]);
|
||||
|
||||
// Memoize the return value to prevent unnecessary re-renders
|
||||
const result = useMemo<AgentStatusData>(() => ({
|
||||
...statusData,
|
||||
isConnected,
|
||||
isStale: isStale || statusData.status === 'idle',
|
||||
}), [statusData, isConnected, isStale]);
|
||||
const result = useMemo<AgentStatusData>(
|
||||
() => ({
|
||||
...statusData,
|
||||
isConnected,
|
||||
isStale: isStale || statusData.status === 'idle',
|
||||
}),
|
||||
[statusData, isConnected, isStale]
|
||||
);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,42 +1,49 @@
|
|||
import { useCallback } from 'react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useWebSocket, type WebSocketMessage } from './useWebSocket';
|
||||
import { useWebSocket, type WebSocketMessage, type ConnectionState } 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
|
||||
* Returns connection status so the caller can provide it to
|
||||
* `WebSocketStatusProvider`, allowing data-fetching hooks to
|
||||
* reduce polling when the WebSocket is healthy.
|
||||
*/
|
||||
export function useTaskSync(): { isConnected: boolean } {
|
||||
export function useTaskSync(): {
|
||||
isConnected: boolean;
|
||||
connectionState: ConnectionState;
|
||||
reconnectAttempt: number;
|
||||
} {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const handleMessage = useCallback((message: WebSocketMessage) => {
|
||||
if (message.type === 'task:changed') {
|
||||
// Invalidate task queries to trigger a refetch
|
||||
queryClient.invalidateQueries({ queryKey: ['tasks'] });
|
||||
|
||||
// Also invalidate specific task if we know which one
|
||||
if (message.taskId) {
|
||||
queryClient.invalidateQueries({ queryKey: ['tasks', message.taskId] });
|
||||
}
|
||||
const handleMessage = useCallback(
|
||||
(message: WebSocketMessage) => {
|
||||
if (message.type === 'task:changed') {
|
||||
// Invalidate task queries to trigger a refetch
|
||||
queryClient.invalidateQueries({ queryKey: ['tasks'] });
|
||||
|
||||
// If it's an archive-related change, also invalidate archive queries
|
||||
if (message.changeType === 'archived' || message.changeType === 'restored') {
|
||||
queryClient.invalidateQueries({ queryKey: ['tasks', 'archived'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['tasks', 'archive-suggestions'] });
|
||||
}
|
||||
}
|
||||
}, [queryClient]);
|
||||
// Also invalidate specific task if we know which one
|
||||
if (message.taskId) {
|
||||
queryClient.invalidateQueries({ queryKey: ['tasks', message.taskId] });
|
||||
}
|
||||
|
||||
const { isConnected } = useWebSocket({
|
||||
// If it's an archive-related change, also invalidate archive queries
|
||||
if (message.changeType === 'archived' || message.changeType === 'restored') {
|
||||
queryClient.invalidateQueries({ queryKey: ['tasks', 'archived'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['tasks', 'archive-suggestions'] });
|
||||
}
|
||||
}
|
||||
},
|
||||
[queryClient]
|
||||
);
|
||||
|
||||
const { isConnected, connectionState, reconnectAttempt } = useWebSocket({
|
||||
onOpen: { type: 'subscribe:tasks' },
|
||||
onMessage: handleMessage,
|
||||
reconnectDelay: 3000,
|
||||
maxReconnectAttempts: 20,
|
||||
});
|
||||
|
||||
return { isConnected };
|
||||
return { isConnected, connectionState, reconnectAttempt };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,10 @@
|
|||
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||
|
||||
// ============================================
|
||||
// WebSocket Connection States
|
||||
// ============================================
|
||||
export type ConnectionState = 'connecting' | 'connected' | 'reconnecting' | 'disconnected';
|
||||
|
||||
export interface WebSocketMessage {
|
||||
type: string;
|
||||
[key: string]: unknown;
|
||||
|
|
@ -12,9 +17,9 @@ export interface UseWebSocketOptions {
|
|||
autoConnect?: boolean;
|
||||
/** Message to send on open (subscription). */
|
||||
onOpen?: WebSocketMessage;
|
||||
/** Reconnect delay in ms. 0 to disable. Default 3000. */
|
||||
reconnectDelay?: number;
|
||||
/** Maximum reconnect attempts. 0 for unlimited. Default 0. */
|
||||
/** Whether to automatically reconnect on disconnect. Default true. */
|
||||
autoReconnect?: boolean;
|
||||
/** Maximum reconnect attempts before giving up. 0 = unlimited. Default 20. */
|
||||
maxReconnectAttempts?: number;
|
||||
/** Callback when connection opens. */
|
||||
onConnected?: () => void;
|
||||
|
|
@ -29,44 +34,72 @@ export interface UseWebSocketOptions {
|
|||
export interface UseWebSocketReturn {
|
||||
/** Whether currently connected. */
|
||||
isConnected: boolean;
|
||||
/** Detailed connection state. */
|
||||
connectionState: ConnectionState;
|
||||
/** Current reconnect attempt (0 when connected or idle). */
|
||||
reconnectAttempt: number;
|
||||
/** Send a message. */
|
||||
send: (message: WebSocketMessage) => void;
|
||||
/** Manually connect. */
|
||||
connect: () => void;
|
||||
/** Manually disconnect. */
|
||||
/** Manually disconnect (stops auto-reconnect). */
|
||||
disconnect: () => void;
|
||||
/** Last received message. */
|
||||
lastMessage: WebSocketMessage | null;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Exponential Backoff Constants
|
||||
// ============================================
|
||||
/** Base delay for first reconnect attempt (ms). */
|
||||
const BACKOFF_BASE_MS = 1000;
|
||||
/** Maximum backoff delay (ms). */
|
||||
const BACKOFF_MAX_MS = 30_000;
|
||||
/** If no message received within this time, assume dead and reconnect (ms). */
|
||||
const KEEPALIVE_TIMEOUT_MS = 45_000;
|
||||
/** Default maximum number of reconnect attempts before giving up. */
|
||||
const DEFAULT_MAX_RECONNECT_ATTEMPTS = 20;
|
||||
|
||||
function getDefaultWsUrl(): string {
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const isDev = ['3000', '5173'].includes(window.location.port);
|
||||
return isDev
|
||||
? `${protocol}//localhost:3001/ws`
|
||||
: `${protocol}//${window.location.host}/ws`;
|
||||
return isDev ? `${protocol}//localhost:3001/ws` : `${protocol}//${window.location.host}/ws`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate exponential backoff delay: min(base * 2^attempt, max).
|
||||
* Adds ±10% jitter to prevent thundering-herd reconnects.
|
||||
*/
|
||||
function getBackoffDelay(attempt: number): number {
|
||||
const delay = Math.min(BACKOFF_BASE_MS * Math.pow(2, attempt), BACKOFF_MAX_MS);
|
||||
const jitter = delay * 0.1 * (Math.random() * 2 - 1); // ±10%
|
||||
return Math.round(delay + jitter);
|
||||
}
|
||||
|
||||
export function useWebSocket(options: UseWebSocketOptions = {}): UseWebSocketReturn {
|
||||
const {
|
||||
url,
|
||||
autoConnect = true,
|
||||
autoReconnect = true,
|
||||
onOpen,
|
||||
reconnectDelay = 3000,
|
||||
maxReconnectAttempts = 0,
|
||||
maxReconnectAttempts = DEFAULT_MAX_RECONNECT_ATTEMPTS,
|
||||
onConnected,
|
||||
onDisconnected,
|
||||
onMessage,
|
||||
onError,
|
||||
} = options;
|
||||
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
const [connectionState, setConnectionState] = useState<ConnectionState>('disconnected');
|
||||
const [lastMessage, setLastMessage] = useState<WebSocketMessage | null>(null);
|
||||
|
||||
const [reconnectAttempt, setReconnectAttempt] = useState(0);
|
||||
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const reconnectTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const keepaliveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const reconnectAttemptsRef = useRef(0);
|
||||
const mountedRef = useRef(true);
|
||||
/** True when user explicitly called disconnect() — suppresses auto-reconnect. */
|
||||
const intentionalDisconnectRef = useRef(false);
|
||||
|
||||
// Store callbacks in refs to avoid reconnecting on callback changes
|
||||
const onConnectedRef = useRef(onConnected);
|
||||
|
|
@ -83,6 +116,8 @@ export function useWebSocket(options: UseWebSocketOptions = {}): UseWebSocketRet
|
|||
onOpenRef.current = onOpen;
|
||||
}, [onConnected, onDisconnected, onMessage, onError, onOpen]);
|
||||
|
||||
// ---- Timers ----
|
||||
|
||||
const clearReconnectTimeout = useCallback(() => {
|
||||
if (reconnectTimeoutRef.current) {
|
||||
clearTimeout(reconnectTimeoutRef.current);
|
||||
|
|
@ -90,11 +125,42 @@ export function useWebSocket(options: UseWebSocketOptions = {}): UseWebSocketRet
|
|||
}
|
||||
}, []);
|
||||
|
||||
const clearKeepaliveTimeout = useCallback(() => {
|
||||
if (keepaliveTimeoutRef.current) {
|
||||
clearTimeout(keepaliveTimeoutRef.current);
|
||||
keepaliveTimeoutRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Reset the keepalive timer. Called on every incoming message (or open).
|
||||
* If no message arrives within KEEPALIVE_TIMEOUT_MS, force-close to trigger reconnect.
|
||||
*/
|
||||
const resetKeepaliveTimeout = useCallback(() => {
|
||||
clearKeepaliveTimeout();
|
||||
keepaliveTimeoutRef.current = setTimeout(() => {
|
||||
if (!mountedRef.current) return;
|
||||
// No data received for 45s — assume dead, force reconnect
|
||||
console.warn('[WebSocket] Keepalive timeout — no data in 45s, reconnecting');
|
||||
wsRef.current?.close(4000, 'Keepalive timeout');
|
||||
}, KEEPALIVE_TIMEOUT_MS);
|
||||
}, [clearKeepaliveTimeout]);
|
||||
|
||||
// ---- Connect / Reconnect ----
|
||||
|
||||
const connect = useCallback(() => {
|
||||
if (!mountedRef.current) return;
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) return;
|
||||
if (
|
||||
wsRef.current?.readyState === WebSocket.OPEN ||
|
||||
wsRef.current?.readyState === WebSocket.CONNECTING
|
||||
)
|
||||
return;
|
||||
|
||||
clearReconnectTimeout();
|
||||
intentionalDisconnectRef.current = false;
|
||||
|
||||
const isReconnect = reconnectAttemptsRef.current > 0;
|
||||
setConnectionState(isReconnect ? 'reconnecting' : 'connecting');
|
||||
|
||||
const wsUrl = url || getDefaultWsUrl();
|
||||
const ws = new WebSocket(wsUrl);
|
||||
|
|
@ -102,10 +168,12 @@ export function useWebSocket(options: UseWebSocketOptions = {}): UseWebSocketRet
|
|||
|
||||
ws.onopen = () => {
|
||||
if (!mountedRef.current) return;
|
||||
setIsConnected(true);
|
||||
setConnectionState('connected');
|
||||
reconnectAttemptsRef.current = 0;
|
||||
setReconnectAttempt(0);
|
||||
onConnectedRef.current?.();
|
||||
|
||||
resetKeepaliveTimeout();
|
||||
|
||||
// Send subscription message if provided
|
||||
if (onOpenRef.current) {
|
||||
ws.send(JSON.stringify(onOpenRef.current));
|
||||
|
|
@ -114,45 +182,74 @@ export function useWebSocket(options: UseWebSocketOptions = {}): UseWebSocketRet
|
|||
|
||||
ws.onmessage = (event) => {
|
||||
if (!mountedRef.current) return;
|
||||
// Reset keepalive on every received message
|
||||
resetKeepaliveTimeout();
|
||||
try {
|
||||
const message = JSON.parse(event.data) as WebSocketMessage;
|
||||
setLastMessage(message);
|
||||
onMessageRef.current?.(message);
|
||||
} catch (e) {
|
||||
console.error('WebSocket message parse error:', e);
|
||||
console.error('[WebSocket] Message parse error:', e);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
if (!mountedRef.current) return;
|
||||
setIsConnected(false);
|
||||
clearKeepaliveTimeout();
|
||||
wsRef.current = null;
|
||||
onDisconnectedRef.current?.();
|
||||
|
||||
// Reconnect if enabled
|
||||
if (reconnectDelay > 0) {
|
||||
const canRetry = maxReconnectAttempts === 0 ||
|
||||
reconnectAttemptsRef.current < maxReconnectAttempts;
|
||||
|
||||
if (canRetry) {
|
||||
reconnectAttemptsRef.current++;
|
||||
reconnectTimeoutRef.current = setTimeout(connect, reconnectDelay);
|
||||
}
|
||||
// Don't reconnect if disabled or the user explicitly disconnected
|
||||
if (!autoReconnect || intentionalDisconnectRef.current) {
|
||||
setConnectionState('disconnected');
|
||||
return;
|
||||
}
|
||||
|
||||
// Attempt reconnect with exponential backoff
|
||||
const attempt = reconnectAttemptsRef.current;
|
||||
if (maxReconnectAttempts > 0 && attempt >= maxReconnectAttempts) {
|
||||
// Exhausted all attempts — give up
|
||||
console.warn(
|
||||
`[WebSocket] Max reconnect attempts (${maxReconnectAttempts}) reached — giving up`
|
||||
);
|
||||
setConnectionState('disconnected');
|
||||
return;
|
||||
}
|
||||
|
||||
reconnectAttemptsRef.current = attempt + 1;
|
||||
setReconnectAttempt(attempt + 1);
|
||||
setConnectionState('reconnecting');
|
||||
|
||||
const delay = getBackoffDelay(attempt);
|
||||
console.info(
|
||||
`[WebSocket] Reconnecting in ${delay}ms (attempt ${attempt + 1}/${maxReconnectAttempts || '∞'})`
|
||||
);
|
||||
reconnectTimeoutRef.current = setTimeout(connect, delay);
|
||||
};
|
||||
|
||||
ws.onerror = (error) => {
|
||||
onErrorRef.current?.(error);
|
||||
ws.close();
|
||||
// onclose will fire after onerror — reconnect logic lives there
|
||||
};
|
||||
}, [url, reconnectDelay, maxReconnectAttempts, clearReconnectTimeout]);
|
||||
}, [
|
||||
url,
|
||||
autoReconnect,
|
||||
maxReconnectAttempts,
|
||||
clearReconnectTimeout,
|
||||
clearKeepaliveTimeout,
|
||||
resetKeepaliveTimeout,
|
||||
]);
|
||||
|
||||
const disconnect = useCallback(() => {
|
||||
intentionalDisconnectRef.current = true;
|
||||
clearReconnectTimeout();
|
||||
reconnectAttemptsRef.current = maxReconnectAttempts; // Prevent auto-reconnect
|
||||
wsRef.current?.close();
|
||||
clearKeepaliveTimeout();
|
||||
reconnectAttemptsRef.current = 0;
|
||||
setReconnectAttempt(0);
|
||||
wsRef.current?.close(1000, 'Client disconnect');
|
||||
wsRef.current = null;
|
||||
}, [clearReconnectTimeout, maxReconnectAttempts]);
|
||||
setConnectionState('disconnected');
|
||||
}, [clearReconnectTimeout, clearKeepaliveTimeout]);
|
||||
|
||||
const send = useCallback((message: WebSocketMessage) => {
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
|
|
@ -163,7 +260,7 @@ export function useWebSocket(options: UseWebSocketOptions = {}): UseWebSocketRet
|
|||
// Connect on mount if autoConnect
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
|
||||
|
||||
if (autoConnect) {
|
||||
connect();
|
||||
}
|
||||
|
|
@ -171,13 +268,16 @@ export function useWebSocket(options: UseWebSocketOptions = {}): UseWebSocketRet
|
|||
return () => {
|
||||
mountedRef.current = false;
|
||||
clearReconnectTimeout();
|
||||
clearKeepaliveTimeout();
|
||||
wsRef.current?.close();
|
||||
wsRef.current = null;
|
||||
};
|
||||
}, [autoConnect, connect, clearReconnectTimeout]);
|
||||
}, [autoConnect, connect, clearReconnectTimeout, clearKeepaliveTimeout]);
|
||||
|
||||
return {
|
||||
isConnected,
|
||||
isConnected: connectionState === 'connected',
|
||||
connectionState,
|
||||
reconnectAttempt,
|
||||
send,
|
||||
connect,
|
||||
disconnect,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue