feat(server): implement per-route rate limiting with tiered thresholds

This commit is contained in:
Brad Groux 2026-01-29 04:43:47 -06:00
parent 2eef45a5cd
commit 6640c4986d
14 changed files with 1075 additions and 164 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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(() => {

View file

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

View file

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

View file

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