From 6640c4986dacee4e207c509015e863765fe040a3 Mon Sep 17 00:00:00 2001 From: Brad Groux Date: Thu, 29 Jan 2026 04:43:47 -0600 Subject: [PATCH] feat(server): implement per-route rate limiting with tiered thresholds --- .../telemetry/events-2026-01-29.ndjson | 189 +++++++++ server/.veritas-kanban/activity.json | 44 ++ server/.veritas-kanban/status-history.json | 16 + .../__tests__/middleware/rate-limit.test.ts | 378 +++++++++++++++++- server/src/index.ts | 95 ++++- server/src/middleware/rate-limit.ts | 72 +++- server/src/routes/v1/index.ts | 35 +- web/src/App.tsx | 8 +- .../components/shared/WebSocketIndicator.tsx | 81 ++-- web/src/contexts/WebSocketContext.tsx | 13 +- web/src/hooks/useAgent.ts | 46 ++- web/src/hooks/useAgentStatus.ts | 45 ++- web/src/hooks/useTaskSync.ts | 51 ++- web/src/hooks/useWebSocket.ts | 166 ++++++-- 14 files changed, 1075 insertions(+), 164 deletions(-) diff --git a/.veritas-kanban/telemetry/events-2026-01-29.ndjson b/.veritas-kanban/telemetry/events-2026-01-29.ndjson index 2afc7382..d67a6c5e 100644 --- a/.veritas-kanban/telemetry/events-2026-01-29.ndjson +++ b/.veritas-kanban/telemetry/events-2026-01-29.ndjson @@ -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"} diff --git a/server/.veritas-kanban/activity.json b/server/.veritas-kanban/activity.json index 6ade6a0d..0f229feb 100644 --- a/server/.veritas-kanban/activity.json +++ b/server/.veritas-kanban/activity.json @@ -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", diff --git a/server/.veritas-kanban/status-history.json b/server/.veritas-kanban/status-history.json index ade5c4d7..c02e5cad 100644 --- a/server/.veritas-kanban/status-history.json +++ b/server/.veritas-kanban/status-history.json @@ -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", diff --git a/server/src/__tests__/middleware/rate-limit.test.ts b/server/src/__tests__/middleware/rate-limit.test.ts index 2473487c..bb07b2a3 100644 --- a/server/src/__tests__/middleware/rate-limit.test.ts +++ b/server/src/__tests__/middleware/rate-limit.test.ts @@ -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); + }); }); }); diff --git a/server/src/index.ts b/server/src/index.ts index a941142e..7be53121 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -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; +} + const wss = new WebSocketServer({ server, path: '/ws', @@ -461,7 +478,45 @@ setHealthWss(wss); // Track subscriptions: taskId -> Set of WebSocket clients const agentSubscriptions = new Map>(); -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) diff --git a/server/src/middleware/rate-limit.ts b/server/src/middleware/rate-limit.ts index c2e536e5..49e0c456 100644 --- a/server/src/middleware/rate-limit.ts +++ b/server/src/middleware/rate-limit.ts @@ -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.', }); diff --git a/server/src/routes/v1/index.ts b/server/src/routes/v1/index.ts index 351f34cd..f1d3acc8 100644 --- a/server/src/routes/v1/index.ts +++ b/server/src/routes/v1/index.ts @@ -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); diff --git a/web/src/App.tsx b/web/src/App.tsx index 956d9dc7..414f77a8 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -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 ( - + diff --git a/web/src/components/shared/WebSocketIndicator.tsx b/web/src/components/shared/WebSocketIndicator.tsx index 9247c40a..247b7733 100644 --- a/web/src/components/shared/WebSocketIndicator.tsx +++ b/web/src/components/shared/WebSocketIndicator.tsx @@ -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 (
- {isConnected ? ( - - ) : ( - - )} - - {isConnected ? 'Real-time sync active' : 'Disconnected'} - + + {heading}
-

- {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…'} -

+

{body}

diff --git a/web/src/contexts/WebSocketContext.tsx b/web/src/contexts/WebSocketContext.tsx index 746c35dc..c644f23c 100644 --- a/web/src/contexts/WebSocketContext.tsx +++ b/web/src/contexts/WebSocketContext.tsx @@ -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({ isConnected: false, + connectionState: 'disconnected', + reconnectAttempt: 0, }); export function WebSocketStatusProvider({ children, isConnected, + connectionState, + reconnectAttempt, }: { children: ReactNode; isConnected: boolean; + connectionState: ConnectionState; + reconnectAttempt: number; }) { return ( - + {children} ); diff --git a/web/src/hooks/useAgent.ts b/web/src/hooks/useAgent.ts index 04c591b9..4249b2ed 100644 --- a/web/src/hooks/useAgent.ts +++ b/web/src/hooks/useAgent.ts @@ -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(() => { diff --git a/web/src/hooks/useAgentStatus.ts b/web/src/hooks/useAgentStatus.ts index b8723ae1..92a454e4 100644 --- a/web/src/hooks/useAgentStatus.ts +++ b/web/src/hooks/useAgentStatus.ts @@ -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 Agent idle; * } - * + * * return {status}; * ``` */ @@ -82,7 +82,7 @@ export function useRealtimeAgentStatus(): AgentStatusData { subAgents: [], lastUpdated: new Date().toISOString(), }); - + const [isStale, setIsStale] = useState(false); const pollIntervalRef = useRef | null>(null); const staleCheckRef = useRef | 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(() => ({ - ...statusData, - isConnected, - isStale: isStale || statusData.status === 'idle', - }), [statusData, isConnected, isStale]); + const result = useMemo( + () => ({ + ...statusData, + isConnected, + isStale: isStale || statusData.status === 'idle', + }), + [statusData, isConnected, isStale] + ); return result; } diff --git a/web/src/hooks/useTaskSync.ts b/web/src/hooks/useTaskSync.ts index e388e02e..16e39102 100644 --- a/web/src/hooks/useTaskSync.ts +++ b/web/src/hooks/useTaskSync.ts @@ -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 }; } diff --git a/web/src/hooks/useWebSocket.ts b/web/src/hooks/useWebSocket.ts index af8f1a5a..85dd7724 100644 --- a/web/src/hooks/useWebSocket.ts +++ b/web/src/hooks/useWebSocket.ts @@ -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('disconnected'); const [lastMessage, setLastMessage] = useState(null); - + const [reconnectAttempt, setReconnectAttempt] = useState(0); + const wsRef = useRef(null); const reconnectTimeoutRef = useRef | null>(null); + const keepaliveTimeoutRef = useRef | 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,