From 041b2f8dc2ee2e1549b38ba275aa341d8683f4c4 Mon Sep 17 00:00:00 2001 From: Michael Freno Date: Wed, 7 Jan 2026 14:37:40 -0500 Subject: [PATCH] oof --- bun.lockb | Bin 471422 -> 473716 bytes package.json | 1 + src/app.tsx | 10 -- src/config.ts | 61 +++++--- src/context/auth.tsx | 40 +++++ src/env/server.ts | 6 +- src/lib/auth-query.ts | 62 +++++++- src/lib/token-refresh.ts | 166 +++++++++++++++++--- src/routes/index.tsx | 2 +- src/server/api/routers/auth.ts | 240 ++++++++++++++++++++++++----- src/server/api/routers/database.ts | 6 +- src/server/cache.ts | 207 ++++++++++++++++++------- src/server/security.ts | 30 +++- 13 files changed, 674 insertions(+), 157 deletions(-) diff --git a/bun.lockb b/bun.lockb index 686ed1c25d5d1ab24cf347f1e45fa1498f8401f3..caa13fd71f4126a6359c26db01430d0980c93216 100755 GIT binary patch delta 63562 zcmeF4cX(7)zqV&48O#JCgc^DcMS6*e1R@a;2sJ>YmjHo8Y7!y=!2pV46f};wP!U9= zSdk_uDxx9?qN1WGqM)KEHUyL+`rXgoYcTq8-tT?ScdqjnbMfR^zk8Lv_S$Q&*;Cl_ z#~0O3o~(9HvqoLxhX2{A{Y&3oHR7vk&)$FRvuoB=+L+X9^SIWFx*wWw*Xzr^y6Q5I z9yb^Dujwn1y*7V!>4Kb$(OJ2$t&2ULvL4T@2k|I;o%L0CN#X@~DSX1DDOr;ydOTBd z$7hYCUcwT$yl1JK|0{8%%4OtCNY5JW>HUb??)dbHW3%!mc*-sFc*Lyw(XE?~nlye=PMg2tDOnRTJn;0Kob+jLKjrRMW%4!f zQMd-0e3i?=GtzT2x_OR0;cm!l3;b&1L)Ond;|^5v8h3y`gh$fQTh?F0HGnTX5%iT! zimbBE&0f4Nm{K;$SNoA*Vc8l5Wgl^O{sf0pg&)}HdZp*wjsFg>coF`cE#KmKdo*wk z@2nEm&u((t{bjQ|dY|Lv$bZZF3pmrSVAg6kG3$Qob8#K+Dc1MchL*f&hZnA+)X#b+ zTu1Lp>&q#x!_dISYi%53p^&#Ys11D{&oYAY7#>mE-P!sV|8+m^4N^8I#q_rAb&uok}Ij?b;QcKcnp zTFJ6N3jrB-zM4oR9xp`4V&KvuTEUo=hlzHRj=k=cUF`jU-gf?yApnr|y1q z`po0053hsQ#s56$`a!%7@r$_n^^o*a~0Lk?O<~aUHIeU%BHyC4F-G$nhDTul#PkTfcVqdpxcIOT`(OS#5CD zFOREZr;oS;_c>nKQ{b8PDhU->g{#7RTmyI`UI`z?hD+jY7#YpJg~!}IzUF(6ryg+w zT=n+j&UyQT+kwVEx+8QBZ$P9sqtUrzxIQP_{c?<&r&%N6{@^N$^3f>r3hx(mY zM>#UNnOWnqSnpf^;T{nmu8~-VYs&Qd)Ah%#=VXk{$fx{u#OnAF8j@F`yv~^u#H#m^ z<-2fg_&KlF8G-3jv!;y7^my*@c|Bzd$Y>qrb)WcfZ8(<H=SI!k&wex;^HR}$Lr?E!a# z=fb_tocswd1y8gcXka@O?F+W8QorDVQeNleyaiW(`$xIe#uHZ}9)hc1oi2B`ABQXM z3Db+UqPvOK|q1VAgFU)bB~SHaHMh zM><+>hHL+-;Mzb0u7Nyf8-Ajp5}|u5v3Yxa}6cC;hwYr7uAYZ1rLuJR*s9kipB+=D*4uEvVfCxwI-vbOcyhU?&}PyyF0Y#Zwi zs=;+yuzpPB^3k)K5mN_iUg3^V6t4J1TnpZBjogE|AFoM#qOsedsSVv7d=6emr_Tc< zH1xA^ZFmG;8}Eaw#|gMPSj*;@#&sxur6CRdtIgd#T7_!__ro<(XIprk=b~F$y5)Yv zRj++3cN*)E7F_I@uAzZO6kLF-BZF{t@aqO{!*z*uOxxlb;*pc4=4Rx0JmJLJ)3+(7 z9a)7pz|UOi#`B4_BV#P@+`wDlRHzNm(ta|*9l}5AyFEW({RLbdsg~$=Y%{J7tbl8h z-ifQ>ciOof*nn#!esAk`uw0Vc0S~SPWj|bpXmi^Fx4`_X-61q&XhTzN#atTJo^0e; z>%^QgVKOO?Cy2!JlifMd?;5w^QR$W zX;5&jJ6oT`b>=>VYl)eGJ4;L_cM@K8ox8`maGmK5h&9<_a81g9^*<J z?T?)_d9*fJ8m^8N(vA*&a74`*U#*V8={4&YEb8vIrGhH+c29SkEql3TTM=tpn{f@s za$KW-53W(Y8CRdKOLYfxH?C>Z1lQ4dxQ}~+RU}sZAhEVHfqL|%VAh26oN?3G_z)M& zO2#!9p$(^L2Z-yzH8?j+&KTR2mp&`f+=ePzzVdpvRraWVb z)$u9mIZempaw@(#z#X-b={XrqC*^t^A7h z2Duw3q#YfFK7-wn%bhf8oR0Xn#9E_1$F=9z;@XioytI~rszcoZ6EmhvNY5ST8B45& zzQ%PpGjj8$WR1`D^c&_j5W2XDr2&o9*o-Mn^K!C0p^No9hP(BCAL5=EDI5>AGdX9{ z*qn^q+ydLty6G$h>e;mPyy=>%?+-99o$Bc64f@ie5WW18;9+D?<&6(^h_r$uBaymqkJTImTU6qB7hiBA*6y3`6nT}vSi1FoxDE~FvnEbS@YJyF ze#uDbbli<=?b?8A5nYPwkc{j~LdP%-*V=G|W2z0Wzsa3!pV;`C87?0^X-ZQj(&PkU z&F09NZioN0`F9elV;K{(^RjX>aw^;$JX^P!udrbK74@Dg-Qv4tr5Ya}(PPT;mY?L; z9oBm5i+68ox8?oUHk{di)u0Df3_E=LUF}oK#AO`r5I8mJndObc)*ou!q{!cz2K~Y4 z5%Gb&i@AhVWcJqdCPX~!@wB^;*Y|JH-9!l&@^%ol4Mjfhl3?Waq`-|!+*cpIU{Pkg ze>G93VAS?RU#X?RUE7md`iaPPo)-?5d!l-)_bnyZ3^yORP7cn|7Y@;t%R zF7bi)h&1+ed|-SaX0^xD#fgd*B!m-m2+kjy7`4lwJnn8SB3N`oeBf3aIfEH^kEknEIUHH>zN$|IQ{GPU^?D*$_%=Oxf)|A3T^1~w z5g+LHq&tMvV-Fr7QZK`TMfnL41Ul?)R<)jW z3zi5*4~-A_*Vq|sqPtLyD8;FkIwn4_*ye?qqJW#HxgH)=UioL;{<52FyyssLdp3{A zc5`67{}rMugFOzki?G;fz@M^~Le5^zQOfE0UiF-)joWh?uD8w|c5kq#YrMZVQM=&$ zHxdJ@NooH$R7{1VL>j!yoQY9q{e{inn-H!*oAKXE>PjcI^Fr#6P%6-7Luj+n-Qs=O z8-gkOlYCEY2o~;73VcPL2AX5@M7*!c#$e>fNrAo_-O1=PMRDE)@5W#u@F4K7xm@SD z;I53Mz=-EOo_f}bV z;{yvWL`7K%5jJpVNX&~KPe->P$9?c$5!Id|(jvjkAIiD=VzBVDq(GCGJf36+vZ4eg z{1wsLXNcSinnXYR715(6FT3jqd(tgFkVT}Dj2e5g`ma1%IYQ*l?xJ<^f!M8~HaTqr zqlpsTE)gxgke5n#Kl@u${gn&bW6}<~5UFS8LZmi7xDct8=xw1b=;ZGnip<`0j;D>7 zm#nQ{5NV8=I0NGSSHJ4<3<&nfObi%OI)axud-?@Y3nGS_BUtt|_ta-SevpNRNGok* zFqN_Vkf<3EW0Dc?4}YB~xF;hq(4Eu}INNzYA%Z|v2sr4!5$SYdy`w{ox4RJ=W>dq7 zv{d?oMe7nG2-*=a`b*;d=ZU%n=dVi)ba}&aQ&fxnBkJvx;(QF8A<}Ze*-7Iacesn9 z)4RakMA~qeGqYbKQa!J;;c`3O<431BdxsGvU1(*s&Eu(zHjffHXN#6=U!6CDyS_~d z41d#Yx`eaYAkno{kI)eLK6*1)_+3)qx?S$E<*DM8c;CWZ!N_AtzRz|AQ?Q!5gN4{& z$NXkDXGJjT#YF#+JswYgC)Mt4=b0Ebn^ccrj}z^}Nu)ZNHQxypyOGpThrLg#hm)%H zuE#UjN!>)sY0mfFyTM)GUmda6-IZ`>fJYL!?X4#@%BfNDJx+Khl|^cxlX{hu+o{U$ zhgc4&v|!ZncHtLLtq(#Sm_TZ9a1WUmP=$S=niJg=nHNy44?`Q6>ZG)<7f|d+%q6EE zQ%DUB&i|!dI0?6FSM1l8=AUdAPQvZur=)rX=bvcj{W!SmWRh=zWB%hGhj#4RPeKg_ zNsUv}fj>w&>%a4qoP5ANxXw8f$g`18S0{nTiQ=6(rIqh{qO07910VZoFy*%-|ME|n zTEQN_CHlvG=J8}YsrN~ZbW(Ajhf;>r2#5VfYM7HsI}}Q-CpE-jr4NTv<46q(&i}1l zI0??<-x34aU%0C+tM^+8-Y-kT3Hqb>8TORbC4y4MhC3Us4k3W-2bo#E# zv*(U2Oz|f9$9&I57WVKa27V+p+{tDs_VxI|M0&|z`vcdb!TC2O`g{E7@r()fSl=%E zCy(d0VAKryO6o=@)BLO<6+2F9y2HjD_jo3X`93~wBEx7b{ue%^bV@x=s#S2$z{J1- zQZ3jlCq1im$rJ7p!1Mpf_&_fkIoAw}(U1&8B|Eqgy^Acf6eBe%^ z?zG0?8x|ipOr&*{Il_`#{kKqF^nLOEz-i`sFsfUkKZn#c&XvsuQrA-6ALLouclxv` zj9?6!pV9ff=Y>Sy;xi^PK!3`fb;IZO8M(pZ4m=>D?$BF+M!AZ~j}}A4IL3 z{Z(EIkJs6I=NZL6g{Ym=y#HB}ecYta>-8kL$qpoKff*#*x?R~pGKD4s!PFt~frohe zy$umd2#dx$L^?RE!b9Wz-NU>(r@1;@Nos&zDER*%)i2m%Ok$uc_o}lTk|sNIFYWP z(}{W#xh2;SY0@}X*ZyNf!}LV&zcYeSa8d_DsX+69+ba9lE#ChSQE&C$e~eTwGryg; zT|}hUSt#9o&L!&Z?DJMqy_E`-F3CQ#J!hHqk0Rnl6BibSR1%yMV@SMjUrAF~k)vO_ zl-Y&*xA2JsqsQr3Aa*}~X zB=yweac0q8B9{CyiN4YmOiFb+)|HPywBYl)H5eawnMkJwqsT0{yrMfh$(z7&C(`VU z2u42@A6Q7F)3~x`m2Xc)v#SQ>TS-%VPi1y&KFPsmPb&^?4C6)>4lZv` zyhNlcOI~lDjrUz$*`(B>J5g2KaiYxhgm8l1T15TNk?QHB!mB#-iX(YFsq5SZcM(>Xg?D|_? z6+S+(?QsEkj7W3espr3{Cez16wP4pssRg&aV?;xoD4Lgoo%tY!XVKk>fz71)gi84$ zYnzl<#%xe+Q-}w))^ZqHcF7PS@FKvDzA?r)6`oF&p>c3!Xgo7LA$ z+7r)qt_TgchAf{*XO6qWhlnymk-rNcm$YN0&t3tfr0?= z(ui1&V;QusNRHMf0$up%M@QAKm*M^;L_}oXK#2)P=qCwQpkzb>b{)>^8tp=p ziV+TsCeo0&gMZg;y~-zk=v_&&l^Cb~KS~IhTVSO$r`(wPWa9W(5eGC2C4po@Qvd^|kKs zx-T2H5@}>;hs(qIo!pa|Jl-9h*vaflq_y=>O)oArm|nfFbLXyGV zNog&0E`5Bhx|+zV+4oUhO$r{kudBQ7?hB!}h}67$O;aL;>jNU5QS#z_b-SCC4%8gn z-4x=1RoySt&7LtWXd4VBw=$lqo95J6JtUVKT+9=@oi<(qP@y%S2uSeIJ_U zbS{;}ykT$m94_I!-!`2{D-V}7JPYsaZ6Z5TBQuqW1NE8Iw%&N((Nt3?oZQE)%2W34 z_^{{uc!PJQw-1!=>u!|7vEKN=KtlD5cRhGt{C;IR7i5P>=~|J?jEM=}ekP?8EspFL zI=XsK;|Ze96!Dm%NLs3zc45LXHPeZ-zH%T3#rxL~@rK~a#J~|!nsy!&oyJI9Zz4N0 zfG=HdQsm|Pn?iX;f3ph@Z0zrK-ViGlEP5azf&dxPE(ju-T>jQ_v1(ei$qB5IfM$Tvp@` zPtnZ@3iSNtzNgw?s5^s+<|Kp@@QyB5$g4@&<(oI8stnU^at$3AMoPQs&er>gG`u_& za;CjYMx;s4qQm8Q%m|h{A|^ef zl|!UPFEi2lScb?Q$<$jDd?UQU-5KqD&C|`U-WQc|H`L+uGm zJ4XY(iQN8c)xC>I>xO&8-?kA)U_!jF%qX*~53M~o*6W$lyEMf!DMjk28_iGl!GMn-ux7@umF_NjQ^>k~{$e>&@%2sKgHdlP-POf?Crym4WInC@P@yI{&y9c`DwIzZMG>K$cE-*hc@ItN^*2?{@Fxd_Z*Wlh)uqk zV+!RJa?LLJEx9IgFkN^#*WCjyt{LhYQ;6JwoljB|*uCU>-^u7Ph!Jm{XO~N-oBkq& zX1;dsr#!Q32yKqM(H&pTz1h5nOw^i&IEi@LIY@Lhk=N|)%}!RE8k%Z46bp#lHC)kt zA}v>3K=CXTnIAg4^pw!kM)YoYy#HPzUa@B-20kF=9?a-92@%uWOJ3&q0zR7~>gROS z{{qPrCm9Hz&eZ`M;c4)}cz+R5H?yY?JM+VIXD<5d-^6m|qA53-lynCAB~W|QBb~uG zOHyaB`x>{$j0@*8XThCBI*kHOA73Nlqe0H@ax>j?h2h~9Ob;S=q0(#I2Z-D?MEiV@ z$S!FNU9FqllDrIIr5;A4*HA2e55@b;%_e0uU3(px9GuVdp})XArJZ+qeH{u+WCn-k z6KH>DcX_|bpEye^EU#dFlV+J+8Q!)9ECN~xoQJ#)8tR6QmnA*P#>L$S%eGwZML8%8 zrxyU%W^E?GD6HbCpy3KXAMK|GeUVx}pD~eAD7Ru1cX- zQ2_BSzw`K;n@*YD23(zbJZ~W#@;wT8$V;I274VR2DtxGb$HiQm`P|91Zno3BGa`q; zYMytForhdA?n^iPoBLtBU+X+B=HcXgkK{i)rT*?}_ZK8T>G9m)6d`fJmGLW5hj=^E z@zb~({2l3WF^_@=RLQ!Mkv5k5&G{^EO;hRyZyEiGarV57El}KrqWPl%EpPLSyHExG z$Sd0X;?DJ*XDD1d!Zm^ODDE1;(R{VY$slpTwLzYlorhdWo{61Dao3UONzdsZPejh+ zU%5t(XA!47>$mgB_p{~bh#~mG1q=QPX1;1v$ovDT;sIfxx8`{{B?k{fep4|aaV;+aQO?kifpm| zq5>YpJK(B0MZA?$;4=!S%;&bC zT**V$4_ht|n5YTfO0`9fS$r`MGo2=ICXAopEz^jio}X=5xsu0iELR^-;yyE9eJz(OnP%g^oF_D$xS8F zdz-gZ@w+T8?xo<1;L3jh*RVW-`%U|7hJnbl(q^x+z8cp}c2+K^C#-;4eiu8y1}UmN;O zZlZGS1U_f0{VP}Vzf(@@V3gXhu4EbhC_mbAxtc78m&WT@ei3&^P8pg}jc}E1V!gQn z9>rZtM;puK+R&A_2E46}+uR+r;|%5_gzEDOT-8@vUuAu@^~bG0fvfx)To1WUx{WrLtDWcY^7t+r@5WW{ zU0e_R3a8*+fcp2o%_#2T``{(a%o};-pzt7}N*=Ps{~gzEe?@uK|Jv63cU=4NjV+J6 ztJqNsin|*A4qgL~U~7saaWzmrf1~adB7uA(qP(54G|C!^ixspf)_* zR{VEd8yaED%N3{FxVU@G%xU)A97VP^lVOX=Rc(y*vDPzj9kFq^9&**2VB>$~TGpr7 z@;BmYXIi+bn}h0ynwZ5OZFjcKztzTbac$>zT#tXp)qbI^f2XZ?F;~0u!|iKU5>ChN zu@#G~-)kEv?&@d|uD5HRwB??{)!;KWUSs`P>uasA!&PpBjW>qZb@%)^fO`Hsu7_M3 zdI9&D_BVMuJB#TX7VogW)B2m%cUj+U{ViPEd)vnESbx{@_pEurHyxGIdad^E0+$gn;J*9c|ddK7o@@s`WgfhoAqG|T0*SKygWLKSA(47oOR zvvrM-_T&y57h1m)*F&zW*!wJBh^yld*!V$Q54pDUFs?mc?&Nd)AF~CDyE?D}uB<1V zf}EAspTad#&)9eku7_NFt>x=*?fDD1HvAHTn%owzQgA4 zwEiZpJ$VP$TSuSZddOApQyc#)FVN5(u@%0t{w=PKe21&2KUn^g|mKS%m|C`?~lq8%Dp0)*xy9%DM`DbzMvHpmAacv;X#^E+DfvZCST#w=|RNCg3 zEy4I}vQz}9p%`3!sIF4hHM9+F{6Fpm#Tr&en%G#b4mHEIr_HUmu=#T3x3au7uE~~Y z<94_fqK=k#!&SbA^SAT;S0NsNfu2=lNZ@dN$ws0$dgD#nplPaQ=B7vETorR{Jk{pQ#iv`JiK}C?aSinX%Zn^u zi1W|0m_K^KkvVIuXG5*%(|BS0*MWFVm8m?o11+H?f z_@g=04%dz(<0{t~S4X?yddS67^;V>Yu&>Q1?kX@4t_}^tRc?qaCl?=zYeOTgkHxj2 zakw6Gbtn&4{i&8;@PcAM4d&Zet_ss}jnGWX<>IO*pKbjXT=j3o^(gLoZ)Kt7a@8|7 zE(j&$mC1P47FcHs{5!5aevb0$=<~Mz7F>KQu8zH8eH*TaoQ@QDUbEm|xfKrZJPEEH=wS2Z8sg5l>UF`@!EQERt`77HIG^h~3Btd- z8ce02&-`#ZFX$8wwDskxKNMH{!)!d#=F7Fc4C|RTe>|>sCfYa$*V#K2S3CKZ&%jk* z7f0I8ExJ>ZD$cdw4qO!qZNd4trc4mmL#~R(`a`(NFUB=6%WeD^t_`ii^^mJWPucvZ zEq^+o=LsHvf&V*JL(ft{8{U8`-h`{;&*SRgR$LFc)}=Rbb!?aAa@Bv^#&YGqqoGhk zdu@hX8SmLxuKW*dT-?P!wBEV1Kqz;My#V>(48@As8 zDfl1xE{0nC?|rF+X6JPogh{oYA2Itb3KrVd=@ z1mXem`fubI*U-nPi(eS1C*1gZv`S_vQgZpfsH!PJu_U0>ZK2`X3 z@@`u5dfb*z6>B|w@|i|?`*wWY`RcA8q*uDG#CuPUYX0rf)%{L-w;kxXa_w!eeV@4b ziKM1OD&HMb{hnTZG_FMO@Sik9Gd##H7~SZZlDXd@%*F)9yMvdG)#lbAAdo z{%k->*wp#kn|w90={F_Yb*!~AX~ePCBVQf0dFA%mgGW5y_dt`^p5E|Toh@TOTDE6) zl}ckrmc9J-V%__#c=ry*4oP~eVb`-a)|htxc<+60U;jbU&7ZfOm$qemjnt^Jr#sfU zrg`00Tm4Zv^Wfv(4t>AwGsEIejc>5~m!z}z-#hSynya3A(7dGXU6%ghu)Zt1pK3Pd z-P4CUmYDcSjTPQahs)eDBg^)8gqAv+#7%D`RH2%|2$+S4EDvCyn()M z-g9Q#Kwp2;aELF;#0~v08VXr79I|jI&0J;<52cwdBLJO-0RpCI7~r(PDS?tEc{pHoI$*_c zK$JNyFkmDgZ3LjSSvCR?F$&;M2Sl6HbiihTjRNJ2cO)QlG$3Onpn_Q|5R(C@FbYt~ zq>lpZ6xc3M*+h>9J zV8so9rslZ7fC+%Kae(G#**HMNM1X%hpruJ257;cQQJ}T)P5@+10%S}8#F@1MF_Qrm zCIaG3`b5A^f$aiUndnJ?{A@tpBtW9sCeSbk5IY&r-egY(>=)Q4aJ8wO4Vae;n41kq zHhTpUrU2q{03FTj9Kcb5BLbaF%Ur;sJix+SKxcDUpv#SbPE!C~P0!0sc9FY?C?%uvuWEK(6uL0?3>T$hZZN zXVwbD+y)&=F5o7!O`zdCK~Y_~~XhP`DTknJ(YWpA6Vk7DnbWtfRrK?nTH>A+r-x*V`sV57kM#`_or zKQIGj`^;L|hbFKB`^cor_M7KrADifv*e51ScED_teQGMN!Va2j*=OcW+2^MAYV44i zCOd5Q%Dym-9>=~kvt?hIk7Y+p%O~j0qNnK2!YAm?H|DTFm!|=po&_H_}X( z&45nN0RpDzIlyUwQvxMT@+QFQ=K(7=0iw)tfdMZ7(l!H1n`N5;5nBNM=K;|s^?AT% zfsF#?jQ0gV=8J%g7XTH^T7j6C02Q_XDw*^xfSm%{1uC297XkS%1M*%3R5jZK8g2!| zz67XlvR?x17uY9I)6{+$Fz*$>+?N5h&0c|oZGgC~fVyV(R=`n#BLcCektx~+I4y8Wps`7Q6|nksz=~G^P0ewE0ows-uK}8yWv>Au-T?St z2edS)uLCv1! zW}866U4Yn~fc7SPCt$z8K7p%E?Kc7Qb_3?V2}m}31rpu@#O(rfG_!XBjtU$R=ww>% z1}xeGShyR|*&G(=@;0E;TY#>n=q=8z6!WdDn@Qe-bvF;ldYI#~o~G;DSTD0o*4vzs zrJB@tus-H-SzqIQ7wcyR%F@hQ+4Ux{7wd1*WdqFfvVkW0Jvxy80UgMDj}8nr+XNc! z1H`@$7;3WL2kaNvCotU9{s1uVL%`e*0O@A0K*C3WxP5?8X7)b7(T{wWo3HozQgq$$ zAz;ydN-X@45@XF_fi52dI(-DlGDRN&P79n87-y3A16F?mSg{{4!5kMDZ~&0@F<_Ee z_AwyhQ-J>yK(0l9spz>1Y{fl%|ssr z0Q^S)MJDwKV6(tRfuQkz4ahtS$oLwt(5w}R`4&*&8-OwC-vD+B zY!`UIL>~p@e+S4r3V6tD6KHr05c@6QVUztWV86gVfu*MQcYt}{1Ll4QSZ4MLB>Vt~ zI|f*8W*-9_6*wZW!nFJzu;@p?!tVjA%wd5pKLI-Z0C?OK{Qx*Ea7y4wll&uK_0ND6 zKLVaM#{~u)2c-Q3SYwv`1c>+r;Qtx0)};On*etM7V7>7k2V|ZAWE=-wAi9P|yKLyA;0oY=;2{il_5PK5vlF2>^*e|e8V5_Np3NY_Cz}!=S zZDy}P!f8O|X&#=8H!XhyEII>N_#0q{IV{lSETGeAz?-J%G~l$rDS_Q4 z`3zw7IlziDfIa58z<}QYX=efNm}O@H5$6H^bAY`j^&DXHc}~;k&T*Q)Z@j+)GXJ1N z#_yEaXVwbD{0XRV9`KP#KM&X`uwCF|6a9y;|HAw*hT{+717;hsY3K#S{s}l}vi}6^ z7uYB8`NG;^jD{~PYT?|lZr;NS_Yx+AksaqH{?g3$0*(qC5jbL6`T&c<0SkSAZ_HtV zE`C6#Fu=E_C=75~;FQ2IlN=6MT>`Ko9PooVE->ISK$;)$lUe2mL_`4mB>=}wY6-w* zfsFzujQ27?W&n_J8Q_#zD-aV2s1O18&7?;Fb_#44IAfv%fc%nxya3>w*(T7i6d*Pd zaNcA`0`?2+6Zq5AE(w?y1(;hhjLVF0^Ipj?E;BBN#Fc`Cg_}90AV)=xi1@=zt0>5# z(vXEwG;^6b97Qu-$^bfD4hWc{%K@haP6?DW$)y3SqX8>Q1ES1vfdOR!X=MPV&9X9p zh;jgbG$7ifMgukrY!oPGyk!BIC) zUU@)OvrV93B|vNiKy{N{0kB_SpFmAhyCPs-3}9|WKy9;EAfYlKt`eZGnOzBRRN#m} ztZ5koSX2eDFb2@T92V$O70{_Npphx63^*-tN}#bxt^!zH4X~mLps6`7FrYdhttz0o zSymMgQ3K$w254zgs{u9(Y!qm1yww4jH31pb0dZ!nKuj$_g&Kf(lU@U`Q((KmRourM zkY5{+R}+wEwh1(>1Bk5!Xm7G>0rm^*6S&&cc21bOfVs5+$!4!WLOnoS9Y9AjyAI%} zz!8B?re$5gqFBJfx`58+ut1mkfKK%QT}@Fvz-fU~0^LktUe&35y0O7kZMvJ05%J36zFTb4FQ=~05Tc^(#%?cn8tt#jR5^kdLzJ2f$ahV zP4pFj{3d|BD*%JdHi3ps0kMq%Lrr#Lze%1CV+X(0CSrF(#>9hgyw*_rhrjq zc2mGnfg=JLre!n0q85OK%>ZM~VSz3!0iBuyvP@BPz-fU~0^>|_3&84DfE6tO6U=de z0j&XPEdi6vvX+2|HUNJsK(B3@%t zYR9n4yyE7O;b9T}Nh`Tao=NW<7Bn435I?e-{~c^oT>ocX_O>M3WV`)7yE^ZCp1I~8S2;GVF?g1$xFH{E}0 z*aC0l@qX^lMElZAv)jVn@HZU5FLApqYE$OjY}Ubh=7oLgD;Th8;vHf4m+-ZmwW-8C zVPy}v{hrl1tZihMY<=p;PsmL-b;gCatNzEI?nA6GS$g|i-`vonv6($Cyj(#Oi}hVV zZqV*Ln!@z8CpE^c%$-MbTTcI_A&@^wr|(#4q-~FNa%JG zZCqC1+#=$2wZJ2E3o-py-0vu$N9Zh~7)l1IF4Q2$fnCCd_E>a=dW5c*%Ty0e%z zta~C{jr2&gOm|G|p!;3u5xVu5-p%ji5N zZ*}WD=SJLk7hA3ZX@23sd34qNCe*`<=m|SqDV9}&)wCa;b+b(WakZ{x-7V8?3hG(b z!!q5!J=U_Gmg&EYHnyx6Orup$jX$AV2c+79)nU8s@$6%nZp7Bnvc8tpgk1|0?q^vo z(g!&B+D@8fwMk!L5ApSu)q&j>y5CKI3+n=#YKHL`U|BuV%`6*eSuE@_%LZ9iAC^U0 z58WwFd)EMIu4wM)i@zG+hDbO0(_@(KHzL>wX+~;74YviaAYH*09AQ~wm~PLhhwk>K zWE1qKWxCay+G~n_hG~+Gw&j|U{>7FH-TJM$=HCem$J&A|V4?ehYmd}mOBAC7j~gs& zMY^(O<1A|pt6|xA%i6$dSvCQt&BdW6uma86Nw%QQ|JFdU$(F^FZfh%Rby4L6#IFoF zk6c?$>jXc%eRg6vZU)%kH#H3)F|Iz~e5MbH9sT1RE{X zJ^Iv{-spbI7QocmRFr60kuBE;cC}^qTGkg<3#N&ud-$onekj(KyU(&T?f={4YLYFq z@Osks!!)7px2!+uLbbxfST;ZbO!H)sWm;-W*m4h8rlq#LUG*NcY%olR*g5_WSvUl^ z%pSYNmg(*ax^t->4_h{j^oN!$v1~Z(E4$&PmW_aYW7#8?rNchAY?&DIPe1JD9Ah2V zM{U7Tq+hVZwcN7Nu%(tgW?2SofMqKz8w0!6vXz#Ng|)V9m1UW*fX z1kAGqpR;T-EZ?$CFl{6om9QJpeH%5JBc_w!(S5$T{`T-XX3CWC?<;Hd(QP$8M!L<$ z>+I=v^oCiM7hW#prbp&)% z<$fp)U61;QdCW~y!&?@NCeZn+(^IFTPCuPmI+b+h=uFX>p))}j|VaL+gtcBP~99GMb3={G;a^J-_52J*Vi#!<)ib+culj{P1$u=`!zSv=zOAwxL(i z>u5WA1MNU>BHi5h5wr|Fik73tP_HnPlOKKsuMZ#24{uzcg)E?t6h8_`;HxA}Hj zc>jWx^nW(t9Hg6d4Mu&CF5x<%>(F}k>p8R;>6T;%hiHUm~x8YNA@GHmZZ_qIxJ6 z)kh7GZdVwIN}?#F+ZyU_lIzh1v=KdrHlfYvc|XglZa=9tH0GQMWT}E7?bvU^aJ`alRrP9pV4vj z3p#;LqEqNsG=k23iQmf1)UC#KoA5%U+luR+<+?q&E_`%Rql*|_pWK0_AhoYc!y8PG z8R5N}^(L8$bg|wS^+Rdsdek53T6!QFgxZFiT{FUawmeVsf1p2+2ku2a6o$f)AC*9t zA^pe6ug$U#SNtwiOKu97RFN~k)jiE5$RC!Nz70n$y?o1+#;Kfl1wu6p9ol_&vSg>;?U9wnn|(6va{x%^J1Ck3Sx3Fp5THQ8~nAYk}tseLRcKp>NSKv0BxDIhI63;uF-0vd&G(~s32kMEE z(KYB=)Cny{52K}M0n+`qbki=~&`UP~+s9e`A^J%7QQJ>KH-CE(y^J=aTado4{R(H$ zQdE<4U8EZeeu;F8MP1+OdiEC7lrm+}*Q9qd$+w_YXf=8q>H1eUmc1WML3v2OX!Cj* z{?y~JW&v(MSDC=n@a6@-vg1*NKjX*IY1%!5&Z2YZca#G^g6ppM3((!@9#n+xMY@gt zUFc~BYZm2nxvLiqdI_YLJY!KiR0q{XQK&rPdb^J2Eh_FoZ=-imYm|hpMjg;O^Zcyv z)@hNneL0~n#}A@2#HWyc3gSETD{L?RKGNm%cJv0?f!3o9NLSPg&^_p0biYZS9bUB{ znPgq0i{Zvd_Y;Xim!s0C42nkjv6|Nyir3M0^ak3A-bB05Ep$QG#KX}Dl#Yg?VW>Ph zPZ=-nLt#kQ#h2;VqasNBMCKPrw=J8@hIHe#m8cxj#c~DoGxOpEI*Cpp-5~HF`V0+4 z!_aUv0;MC}P;@Tid>h)!F@6udk3K;A(1++F|0pIViI0(P{QEN6iXK6_`0jv`QD^iE z9n7F}V^Ai_LN}m#Y@h2|A!>#aBsmL*0=s^K+1Xy5T+aGoAS!{eVuRE#&D} zWmcg_&@!|f^+Bmfzh^TSbwZs{462OulU-Yo?%G!wIrn44!*$a<-39V2(p@8U^UiT- zBASGJ$>9NYtY7wIOT_YmvNF1@)migY|Pur+Ff%2D2r z^p?*z=qq%J&gmVNW=L-ma?^yY=$tmscCHllS%|7l=8Dng&I z(f5$vr_hU5Nw-73k)3GFM)j*Mmy^z+4ZY8B6&4PxWF`?v!l7Qu_rj^5-!$1YL*XQ5h=c;!{u_x)Du9`RFGq4#S6|bmXV(O#E5f zpne-pd;2^pNnXBQ-SGH+gX@v)@>;h_s@0}eZr1dSNn^~o+ry&@bS4|JU(*231A&rhk=}*d^-PCDb z_?oB!Ew*}M)Y_qyRcC)082NWYUlhwO(Fjw${FX`&YAigiS^PHElID%=ZcMXrss z?r6T&)UTN7yswI?AU&08F)EL=ESE)kTJ<0=ia=%1Whe~!P&g`q{7CgxMo-Inde+l) zNfe0!HZ514ctE$Vk0DVBRYVnvrK^$F*`yw;;dV&#Pb+6f)C47<`bf_y4Um40Eg98A ziAaU3EnH+JNe} zM6Hm@se^KDSY=dS?S{5lpi0`a4k!t=M^__NRH2SY6+;~pQ~F|g+E5DWg1RDgvIo-f z?}>Wbw4R4t> z-GubQMN*j=Hdda>-;T60&(=m}A-!U`4Zj7=u|5~S6{&(6FGP2uyHOtHi}2#7U6A-b zq_yQ~bN=@5N@no`;W1Y{0(%%OMN80P^bmRwJ%AP=Eg{Bdnmrg^v9ju`yj)yqX_<+A zFuZ0D6_=D9DpI^kh=-V}uD9jYoYEI!)AB)HpFU%=i@S;{ei`N4{Ks+eZTMEpU%_8R zN6?4pb;JVgJUMXNc{u{x1(t6SQE9ss1x47Q^I*L2x zTG)c2Lf?_0LK;RD*o!Mq4W5FZG^vZjD>XVsQkNMoA+>vg_=el~j+^Qu#OG?oqi3Hk$_N57*ph<~1Q{5gweQcZgqIvmPZ zN7ZT7mqKYTZC#9)i!{|Mj%BU8KL1I;qnBJ?Y zfvVbcHC$2~%2(NtD=jY93;SYi7C0F?ar8=Bmz?cTBGTpQ6{r!ak77|n)Bve5{gB{Q zNGD($T(8Dkq2{OwQkzY!H?uC*l5`6jw+v&>3rg|8D^Z+Pf-R(tg+{0=>^h{^haJ&1 zD3o{YMbcehoso`gH(c*%iKz{T_a-9*RH%Eg0*X~oJ_;X#2BTi6CmMtXq5-Hsx*nyO zmzHu=Rcto>9)=G^Bhhd)0;QwSdhL`Wo`nj~6f_x)K^aJ!mS&JvUM^C;^2Q?N<=_)6 z8;@t{XY$68$V4gtaQp`Ap+?0+WwJ@D;fW};VfiE!Dqoz76)&$1i@9Yus_OAgXd22# z%D55bp}z{c@1Fj%;ZQuCvZ1=g>x4!?Oxp;>>cq_`PeZBU(caBODj1T6`k#BLyj$SP zQ)6>bs1q8s&;~TB+5wG9Xyk4qt$K5jc3L}J5NafJ9B&1xVyIK0&d62yb`+|tz5Zu? z?V0L_Hmc6eL#lrV{s!8DUO;rHKp($2AH!_K*Q0f4EqWF`gS59#qt)msG#jZSkE18h zDpZ8jsg-C2dKhW4J%}Dai;zM0qlHKl^geWx&T7q64TWZ=_9iqEnggNZ6eN8wx*IJ( z+QYk0Xq4_Gtv%G_3>}X7#nNKhMkr4mzDG>QU&9#Ms2Wj0F%?!PLQJs^PpAXp|EwD- zs~u4XLhXh+9GYXwS9=AjphWTGbum`_6kJUChin5Xt5K3mON#NO#nO-1v?gY#EQhhc z`S?sg$MjK~x!n3=*44p(Hm>=gI>m=oi`8wGYkp`>g!VMF4Yj3F)hM3Rn-ywA@JR=_ zv5rsihC^jSjjSR6VhxA#LmgJV;^iJ8-hh@Nr=vRlI&ZbHP(dB5(8N-Mp+;5dpA9OW zi;CCR98sO^`0MCZv<Kr)TK2RGo{R zk~$3^Ksx>Ye(E`AUV+o||IVowny|%3r+8X(=>_x>dJ!p4dWE!hbSs*r^Z%cF`oB5~ zI{yDZ3s7hjoXJF67u%W8Ja~=#;`2aPyC8%8-ACC~$$R z5tY$~l&_7opod@LUm?@7Fg(%Jx+C0Q@D1U2=vy?E*DTvi&Vwquu^ASj&=Blhd+0`e!`lNRxW%LOz2Irhpfrfwv zs2mC)jlg)9}EOYc-qCS4z| ztdF>3kUp*p70@R=l~6?#Ta4@Dy-?*Uq*bRHs)}l%>PRo@YT`9)S}*W4VxhM6Ns>My zR7a$OW&~HDMo7cg1aFL*BJI6C1$I7nC#^UCJD>!l&*SyE;~12I+Mv-$AG!}f`Vd|p z#`i<|fc|Qv5A1uPo~Q@vrf);0km!oKpw8$zbS=6DC8736A8xin+K4)#Lz9TNMe59z zNN@ftPtu;G><;!qo|bNsbO3e-bYPPX0=FU~bOo&Ld!=NC_3teo2Z z=eD}*`2Q1XL;qxiD)+E!!Qm4d2lQ5KD zd?JR*>BI5KXcE%9N&4!QKC8>Yvr%q%0@u$ZUSw!=4(o9jr;zsKCb-@gn}J_VrK7M~ z8wq&ayV2}=CcHkxv+1X2!bkb5jMfFEM^(; zcxH77oEr7a^2T94Z(OsMEt>K6(Szhfk@Mo+o7!!8|FsQ&<*Xv7BssU=)jp+6T*l$Q za@N^8XZBw;=z$f(isfu2rz~}rxBMi(?y%NdZJmOPY^aQPnC-*wd>yz9mA3S`^WEGw z*ZO_lmd#o=YsC)yvvYsd`Ok79+?`EWVq&8Ft$dx9nC?;jD{2m2;`Vj|&c{i!Zi-1f zn{|7?G3C8Zj%ga@uU_p}G9$@cTrX#2$$7)8`Mf{a;(UkkZJMd~|7eD~?gnFgZjBh< z_NAux<^Gr}KV8a7PJY3xw?;nr{!pgd5xjPuI<)3$;`_O|wiv#vZEyL+Vv?@c{y| zbr8}jlWc0k5$>53R9n@D^U9>;dMY&?sIBtndSsHB1&9Hej4Y5b*I@pTbMf}x5=8!43`S_5GPL`tis@vp8uuM{l~$?vu~q9E=ifVSste_8s|?Nr93E3<@V?>lFj#+luEEhB1X z(Hkq6Zk0uz4S}==k~PxlhwmE4w4UiIBi*tn4hXJm7A-O|NEsk3piVU2&ei;68FuFGzEYN-k})9w)* za9}%ys?prahIjzMoEYiqG%Fm-iP4jhUe0*rnrvIB^wRwn35}W_&EP2%SI-td^u$+F|6i^ls&b!dw4c^ zIZdW4&!w@9cY}y~<4voG`F@ZpyFx~^ z6e*jsV}72?-R&w<^m()xyqfMJ;@gYupErL}<{=}3@+hB4j|9R3(!a~EIO?|5|9crR z5tKTN5;ej5_-|jYl__)bs8$p3rUSup?)@8~zm?_Jm@gx?ij?C!W=FMtH6lf(9LS>( z@M;Qys0ZFl_3wXw@R^UdjQA~&QW@eeAnF70HmjmR{(#7L5Y_h6E9Tus`>BN4;k;kS zsBU2;dmbGswUcF4?OjRBnuB8Adt^Fb)kEt&GOyT&|4|%BaVToxq^o4PbpOv*%=?id zZIEkL{wUa~tKD2N=}=%Jenj!3qGbW)*kA(Pa3sAMfcuUJX}~n`)|$C7CJ2g94`KKe z93lT^s#Tf|Krq*jwQTG8BJYWw)jIq>T+vTd)*Oz#iI-tBAUkiq|4P?n z>=Vesyr)I-eh3N+j+$Dxt~Z`^3k1cD32|23{E0km(S2Xy^ddt{=(WGB&1I)7AjDz3 z{20aABBu)WsJZ?p=rB0Aj1zR(77ejJMfP?ooxas6yxu{=i2Ac8oqPEg; z8x$6JC+@rLn3P@e5R`UIySw7TDN3<}jFQt-YzHeUP74)E=R@ty7pVrU7g_*Q6&8hL z(E@FB!pU4#!}Q*He)5nl$1cbSA5fTnr`J1lb$!m>l_|puDU|U}5fO8?uU(pdx}>d) zSPlv+7;#Ds#T4fZTgMg) z5j5KIarM^ruLxBGg3rYRFKxw??1ZW}4G0zow{T^iarbmr``k<2U}8Wju2UgoYMNXZ zO1zPq2JTA>*c>M#Jg=u~(Urjf*g!i7k|}SmQwDf7%}cNz0mR9U)5Dx6&Fe2C zx|dLq7TwqO2Cbk5@@9cYU{=y{V&ol$QH-4o4izD<{%RWtPo zcZ7jzY!Lxj3NAgwD}-}M0{xk79I z2SUhoFB;naS;rw8;8C5B5~ChaHbYDYg30FQY_Y%PJ!2K$8}7{K%OB8fS9s9sAqBW2 z>LcA$Be|9j>8u+Rb$v*jI}{1TKAOu9g^}90Xd-={a%c;uJclQ&sA~BzH_u%apyXEm zi_F@oqBWnCVF3?>z$@YT?F!uiMLN{65NTaTi`zl?w`G*y4nymF8C_-fFUzQwhiazu zT<=(pg)d0>)j#wHt21La3lco=3N!!|dr(4LXD|3}#aou9MA_5Isl)>^1zBpcXs;Tn zsT%HBp8Iusm0rogPlX*|!|liE9l`$SF$K{@}Dvsmv3g_ps44 zzmv+>Z2VIpx-LDX%bg%E_8FOb!T-~q2|e^^hsECO{%>!?Vi&Cko3xfZBX1zM)z4_C z7b@lgAX$v9p6PjIlIx>8^_5$nQ6?ywYoKUBNr;^o#R^6TlUR!$)hi~~S9W?%WnQZJ zT+$1g+!=AAs-XJbs=fNKmqK@$FYO*w=gZ4Id@cx_;)g8@k@H+4H-eN zX;l|A9of*U8(MJcwXh!Y$EHmEj%!|)Y=YLcXCvy;YtnQD;tmk(MaHtQtb6Z)X~$&5 zgze z(#ga}WzG4%B^#*Xg5FXGA9TdFw=|aBOINX329v5*D2n}vNmcWTT0X+NPbvK1#%!yY z!c#4WTcbJcQ8gei{%vPT6Be7Rl6znXn|D`P>+9fy3pS}HCMFG;wB+fhyq|>H3>21s z*CiJ>?~!a`0Sa4OK`!XXQAl_6PhTL|6c#hWVnpHZQ&E41x(JZr9HlZuyeNHG&|Uq; zzx?RFLWW?494vfkLSeob3w2dWK8ubSHTBPgZwH;0r8}u;7iMowJ0Ms=S=QP8 zv?lsrI$@0J3tkh;5&;4jGYZo0XA-VO{(tCn& zcQq<}M}YL$80}|D{(x)3@#RYc$k+FHw&Bu?rWvyB38s|61d3P9JbatMdg`;fFWfr( zHS~^*l*$s(W5oHX+%*39q=O%T)pycYUp#FAIGb?wTDK5$vgo5~pweqyN1M}VKUgsv zU=|SfONpX)S-A%T_UIUyIzH(m*RRh0xCCrHw1{4InCY1?{F zRNqI*bHKq$ZB(;EEhw}PtQ>1Wll#ERL<`!%t}iX96wmdhb%fXstm`zTcF`y=6Q#2j z%TdT<)}SzNFX{ev{Gz3Q|4XK5>yWlDczu9iK5Ls$np*wWW;oOE(RUp!DP$+>D@!`-56j2GU;NsKUtMqOif~hK zx}shK=RHd$Uk%2b{^n@4k{m3kx@z0IvqefXHF*z)U23&px#!+)yG#-~wUH6dY6|!a zT6$xm9fE5ZisoolLlbAkBsDn)sdQBI8FI0y6_pH!AZG|-rBBMvrWM2e_b0;$H>}-a zSl72An}Bx&3G8aFjTIG*Kt?N?4KZ`9D04bMYptj}5myP6hkM3++KOuRgPL1bLVl?+ zW!hie&gFIB)$r>)udHZhFuWupX<;D5uC=DP@z8NN5aqr|;Ur{PBMN~`Lkp{ED3#rd z?K9YrsG;Zm;P>~R>#E?x3yNSf)enLN5|ijBF0KhVheOHyCX_uC*NwQJW>Bi;3Yw62 z6z(rJNgsgLi0qo_O_h9Qd)(>3jl*9Y%M{zK$7YJq8xqoRU28)DA(a#uBpR%j_$9-n z{uZ7aTEi`CMsb5A!wkJAI#bPk+l(#`La8YR7WZLi>bPFERE)qg;6!zrNLyim7KCox zUFfvGF&gNO<@9$NDE6h1;U5bL-iK;UqDINDkk3yI*p*hWn2irNVFgf%ga&q0KI|Q% zn%j{@Jnp4VlRE34jWr%6YE1__VdSq(-gf3oyCm!rNZJMSq;Vj6(%?Z&hZcfIyrX_P z7aaa8iZW^N8k;3`^?zWV6s$tLsSRU8>V0ZKLBmviH1YU^fMvLXoE2%C9oF1rdtCgm zQ!KKlgUolE?8!d~SJ9XH1K?oqs9vwz*Y?R+jXGX3{~9Rl4bbWoZARMOZHJ$VH%N_cNsGbDEon&^%#JNBDWBbo z0jD_&PSyhdqtzb_`n~ZWne%Q-Dq0 zwewP!Zebe&VxCQOAkRo3#V*sQ0Li@a^k~4>2_fHOUx|+fg!yhz96`C@7reHn)4~Tl zg&zQy9jHwhWJo#ksRMV|#5@TFV4v5ToqbFh_CVF$t2r__D$(+)LTabtqt$t_a2ON|maIkEdRk6SDG z#hS>NTRXRG%6cqIkT|3e@ppAvjv|Tv2vjzrNtjnu~vKFP%)ttv?Wa}I#-e? z&8Bt}u+mrIOtnVKM9pj$p&Zcf`}V|yc}s)jev#PAT&OJ0z@g@DS<(E@F_~kYN}Yn| zope@whf>}#GRHtS(xiEz7jlSg7tQ#)PmdPBkyueD-jc}T~4`1FV()4=NM!E zNu_|)y7zS%g82RXVBF`GYLH4zkjj9n)O4_;$?=Gd#16EBl?&8?N@wDlhb!(im+@mC zEbo{VozPd$kLn^%3$Hqm_6s029Z5S8SJ7!7$urV$f4^ST8A2L?)bkR?ZsgKVT=DB+SV@qo4{0p^ zfAFaPZ&fzZv|1_gRq;|M;fykoehH4zvF)HG_mBPQ+-FmS92)++m7G#oOZ=5f&-d0CjKxXp(p1f_XF%6Gb%9}cetcM#e~DROa+h4YIVw)6 z3F<1WPG&uRw%+-dM<@8AgkPPs@u9ABF@ha^D0QwXl$-BE<#SaXxR%|h%{+|#cpRBz zD>*ItHp@KPxwsxUv9-k(QIfh*7(*-sf~}?{oJ(ET=d+7=Gm=*JR(B)k`N#@c-Dnq( z`r6%<{8swj^lh5r3&Yte8e0Rzj9#@CtL~J{WQf{0QRe@IEXtmbRm`eYKk1&n0Gi)) zC+h`pW_4fk$Gu+dtK@eC(p#K}npC&{5xy1tequ{d*gCd)?X;Ckwgw41jcf~2(cYIb zn5_72v=6fMRV#JwDXcW+?5?)#qKoe$(MOO0{Y@6a-|eBr5?Y$o{B!+_YK>RO2uSa@ z5SB{X;JZx5QMESFtMqUo7Na72k>?`7=i#_FQ!r+ndGosVecbCC4(hDwMR7prbAVt= zm#-Rf1GbJhI#xy;1BJaX9a>kQ+ZMF_Z<%tZ7aeB2X1#^Qhl_)*=A4}wxLZcpgTfYe z*Q5>D88PO{V42dfH<>SnWkDh$;`b?M%raNMqc^rUc>|$O27+y2pS{*A@f)i%r(_vx zKw+==CssCZcJ%R$V43n`Z%Sd(Zvep-M1PM-y7ThvmSP$4x;GUphGSRsp{I+n?=KyA zyxNyamw@w5UnPHXFRYczlrLU%V9PoDS&Vjy^1f6*1s(nh2zI=;{*Qw;m}W)1VOvlx zY&}#_-Cv;ekN&IG@@F;N!OJFBba+F5N=iY{*!fdBWCZAdWLr@+wlpp`Io*C4kQh8r zr04<)D^o3lIj58NCspS|0t&Fj4N4tQYWtlCt67}Cl~V@dNew(1<4@&jss>Fl`jYX8 z#ckY8bL;c*6MHgtwo2`)NcE>aU!j2;fM8v7<3V9~M8KfWa5NF6IFf)O&!1)jp+5@* z3!2U5-}b-Vs8J$^b#7;^9wt;Wn;RWIF)Dr{J|p>z&VGe;=7xd7I(O)=xz3J3kI&+4 z9i+E|^frN{Nrel114*BX4v_9o2hx}C+*461-aeM~qlW;0MBF$1$R-W_C$wG1Z5lv! zOJP9FKp{PL{`62q!HwNmh`~EPKcuG)qzMdB*^l$U!~D2(AmuNGiJJ$~)umW%4DqL~ z%V6q^!IZ$R3kTEoWjIT6axjH`jYNy9^K#%P51|vd*UZM}B23QX+qDO;8}40KjyeIU z<7N+`$mNjaHk4K@$5v^JU~)?br+csv4~G}_oD#Y(qz1YT``xTpx&>24I&}04rhVzK z>9=5NLh#IeaF|1vSE+yN-Ulk?fkRq$455AmBxD~T=xQ{K${0sKaIp9(Zj{jJ)faJ_ zz`;&ZA#h`cQS%k>jQiihj(m1m$%1*G26qPn(d&vW#k<2O4hSwNls2wV?bR$8E=(mg zC*A6C@RM98AlVif0uX1dCa%Q(w{18*T?vkiaB8&*_kIzyW;?7HfbTY#6&rk)r|9Qu zOE_hFts8&*BRYagS0Vf+1Hrc6YA<)1G~9YJJ8Q(Bz;ID40)^$OPD>t-Dabp7BYdFX z6p~J{HiERP!Mhg-7P_yv`HvTG3iD#YjowG-o(6?2(AkdPemJG*Ym8b+>a7S$0xzeC zB%3wZg3u|NL{c`B);UsGPIC?n2wrzXo5}`)J1;E^6ju3s{mbPsK_lk>ElZmfNpBeM zMj%*@I{G|ySL?PV*hIzFEPr_UxJVg!b!x-T5sk3g1F1NKfD`JG)F%Tzsu?Bl#(m|l zFZX@+wIH2scQ{AUOd#|@K(v5#C-ZNPtytzaSe8CTq{L(&J~XY}E^N7=3;7(hCW_87 z-lIUUBM{$T`IqUnscVk&yf{|{{UW6_>0W=+m6wL|6uxgPqDk`&EYn8|ylK)mp2=uCPeG_{IXS~G-;Lhb7QkKK`*)o-`z#i8piuK5G`Su zL8NmM^orxQDHEyU~eJusaFfZZY(bSvC%c*5LiCUe0;7`CnK< z1}~Ntk$L!jRda~1@-^74vD>V&rqOG#(f!RxVd3)UB#&-?q`6Gs{mk3?!ASF9(y~U< zK}gZ$gO^3x^GOeX+3&Vu0pCu(7ezfxpV?YuiSe1K(_T;DgaB zJ;S%FYBJv}BQmVX+z3BrPY_o8c5hkodf1>%tVBi_@xw=qBBS! z;=k=)4zfpV#UQ$nK-pU{MFk}aCDtAPvTYaE+)#*Ly}04KjzoI16*|TN!FD73iZsPgN?sU}j|= z%u5Jwd_*bCn4fGn+Zq3fWybTctiNiTjlE}~jGcx`i@luub926(MqWG6ERQ4_yaSQcHHkLw zKpFowNhsFN4t!;Ms96OoCRn(_t!DVhmc1v>eRJ~R=MViR@)Z7<_@e1li=B;VkW3Cc z5lQcyl}OmB>Z4saLzonf?n>-hSJ@3;Vdx8WdfBVUQm#S-S$}W{oIpe?{;jG#~^Y%^)*OmMb5uc!&7@wf{Wm;{YUy7U7 zKM6XE@~943aSKc$n?!<+{}g*i-22`1*Td&NME^W~+}Ji9I)w+!UE0}LKBMWNWcZfY zW7Y+WW^B478sN3Z`;GF!=xsaz8ZM3R`C|2`8}mL$hlTp7rID8c_w_ZVgT&-3a=xEy ztKxp|Z)_l=GaXO1cD&=gWJXSsv4v6bap4o0$oE2*k>33}~?pYbRX;ko{=pJ1(k#I4i4|_4J;gSw_CfnmRjup^;y4OCz z>Z>t2+%r0Py>4ndBV+YWPdsRg2b%DGWhYJV>PCqS8*cWUW%;wq%S7C>I%=s~y`NH- z&G&kQkHndPDQ9-Snl<@%_ZPTlV{zJ;U%QpY=xW@>JqxS6vKA+n{b?F&7Am~y)gB&b z>9E50g7bbM5Q^J>s>nUKN3|+rDE|>^Tc@`C)k*laQ#&zcs<%KGF?!s%G2-75qhq7S zP84sLBgH@CqrxXdj1+I!r&vz$Q4?aLC_GoCv8(hr9?E1;JX4APA5T?tYY{1E0Z90x m5?#;+y6}gA-caOchJng12AW!=@vQ-ozZ$5y1O2Nx-1;x_WV~4b delta 62194 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 P-I`^|JG}y`*xdLp-Ne={ diff --git a/package.json b/package.json index 9bc5a6d..ed14721 100644 --- a/package.json +++ b/package.json @@ -55,6 +55,7 @@ "jose": "^6.1.3", "mermaid": "^11.12.2", "motion": "^12.23.26", + "redis": "^5.10.0", "solid-js": "^1.9.5", "solid-tiptap": "^0.8.0", "uuid": "^13.0.0", diff --git a/src/app.tsx b/src/app.tsx index 634f189..75ed738 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -192,16 +192,6 @@ function AppLayout(props: { children: any }) { } export default function App() { - onMount(() => { - // Start token refresh monitoring - tokenRefreshManager.start(); - }); - - onCleanup(() => { - // Cleanup token refresh on unmount - tokenRefreshManager.stop(); - }); - return ( { setRefreshTrigger((prev) => prev + 1); // Trigger re-fetch }; + // Server-side refresh in getUserState() handles auto-signin during SSR + // No client-side fallback needed - server handles everything with httpOnly cookies + // Listen for auth refresh events from external sources (token refresh, etc.) onMount(() => { if (typeof window === "undefined") return; @@ -91,6 +96,41 @@ export const AuthProvider: ParentComponent = (props) => { const isAdmin = () => serverAuth()?.privilegeLevel === "admin"; const isEmailVerified = () => serverAuth()?.emailVerified ?? false; + // Start/stop token refresh manager based on auth state + let previousAuth: boolean | undefined = undefined; + createEffect(() => { + const authenticated = isAuthenticated(); + + console.log( + `[AuthContext] createEffect triggered - authenticated: ${authenticated}, previousAuth: ${previousAuth}` + ); + + // Only act if auth state actually changed + if (authenticated === previousAuth) { + console.log("[AuthContext] Auth state unchanged, skipping"); + return; + } + + previousAuth = authenticated; + + if (authenticated) { + console.log( + "[AuthContext] User authenticated, starting token refresh manager" + ); + tokenRefreshManager.start(true); + } else { + console.log( + "[AuthContext] User not authenticated, stopping token refresh manager" + ); + tokenRefreshManager.stop(); + } + }); + + // Cleanup on unmount + onCleanup(() => { + tokenRefreshManager.stop(); + }); + const value: AuthContextType = { userState: serverAuth, isAuthenticated, diff --git a/src/env/server.ts b/src/env/server.ts index 897fcd6..be01087 100644 --- a/src/env/server.ts +++ b/src/env/server.ts @@ -31,7 +31,8 @@ const serverEnvSchema = z.object({ VITE_GITHUB_CLIENT_ID: z.string().min(1), VITE_WEBSOCKET: z.string().min(1), VITE_INFILL_ENDPOINT: z.string().min(1), - INFILL_BEARER_TOKEN: z.string().min(1) + INFILL_BEARER_TOKEN: z.string().min(1), + REDIS_URL: z.string().min(1) }); export type ServerEnv = z.infer; @@ -135,7 +136,8 @@ export const getMissingEnvVars = (): string[] => { "VITE_GOOGLE_CLIENT_ID", "VITE_GOOGLE_CLIENT_ID_MAGIC_DELVE", "VITE_GITHUB_CLIENT_ID", - "VITE_WEBSOCKET" + "VITE_WEBSOCKET", + "REDIS_URL" ]; return requiredServerVars.filter((varName) => isMissingEnvVar(varName)); diff --git a/src/lib/auth-query.ts b/src/lib/auth-query.ts index e3f3d96..6d665a9 100644 --- a/src/lib/auth-query.ts +++ b/src/lib/auth-query.ts @@ -28,9 +28,61 @@ export const getUserState = query(async (): Promise => { "use server"; const { getPrivilegeLevel, getUserID } = await import("~/server/auth"); const { ConnectionFactory } = await import("~/server/utils"); + const { getCookie, setCookie } = await import("vinxi/http"); const event = getRequestEvent()!; - const privilegeLevel = await getPrivilegeLevel(event.nativeEvent); - const userId = await getUserID(event.nativeEvent); + + let privilegeLevel = await getPrivilegeLevel(event.nativeEvent); + let userId = await getUserID(event.nativeEvent); + + // If no userId but refresh token exists, attempt server-side token refresh + // Use a flag cookie to prevent infinite loops (only try once per request) + if (!userId) { + const refreshToken = getCookie(event.nativeEvent, "refreshToken"); + const refreshAttempted = getCookie(event.nativeEvent, "_refresh_attempted"); + + if (refreshToken && !refreshAttempted) { + console.log( + "[Auth-Query] Access token expired but refresh token exists, attempting server-side refresh" + ); + + // Set flag to prevent retry loops (expires immediately, just for this request) + setCookie(event.nativeEvent, "_refresh_attempted", "1", { + maxAge: 1, + path: "/", + httpOnly: true + }); + + try { + // Import token rotation function + const { attemptTokenRefresh } = + await import("~/server/api/routers/auth"); + + // Attempt to refresh tokens server-side + const refreshed = await attemptTokenRefresh( + event.nativeEvent, + refreshToken + ); + + if (refreshed) { + console.log("[Auth-Query] Server-side token refresh successful"); + // Re-check auth state with new tokens + privilegeLevel = await getPrivilegeLevel(event.nativeEvent); + userId = await getUserID(event.nativeEvent); + } else { + console.log("[Auth-Query] Server-side token refresh failed"); + } + } catch (error) { + console.error( + "[Auth-Query] Error during server-side token refresh:", + error + ); + } + } else if (refreshAttempted) { + console.log( + "[Auth-Query] Refresh already attempted this request, skipping" + ); + } + } if (!userId) { return { @@ -82,5 +134,11 @@ export function revalidateAuth() { // Dispatch browser event to trigger UI updates (client-side only) if (typeof window !== "undefined") { window.dispatchEvent(new CustomEvent("auth-state-changed")); + + // Reset token refresh timer when auth state changes + // This ensures the timer is synchronized with fresh tokens + import("~/lib/token-refresh").then(({ tokenRefreshManager }) => { + tokenRefreshManager.reset(); + }); } } diff --git a/src/lib/token-refresh.ts b/src/lib/token-refresh.ts index 467ca28..3248a5b 100644 --- a/src/lib/token-refresh.ts +++ b/src/lib/token-refresh.ts @@ -1,34 +1,64 @@ /** * Token Refresh Manager * Handles automatic token refresh before expiry + * + * Note: Since access tokens are httpOnly cookies, we can't read them from client JS. + * Instead, we schedule refresh based on a fixed interval that aligns with token expiry. */ import { api } from "~/lib/api"; -import { getClientCookie } from "~/lib/cookies.client"; -import { getTimeUntilExpiry } from "~/lib/client-utils"; import { revalidateAuth } from "~/lib/auth-query"; +// Token expiry durations (must match server config) +const ACCESS_TOKEN_EXPIRY_MS = import.meta.env.PROD + ? 15 * 60 * 1000 + : 2 * 60 * 1000; // 15m prod, 2m dev +const REFRESH_THRESHOLD_MS = import.meta.env.PROD ? 2 * 60 * 1000 : 30 * 1000; // 2m prod, 30s dev + class TokenRefreshManager { private refreshTimer: ReturnType | null = null; private isRefreshing = false; - private refreshThresholdMs = 2 * 60 * 1000; // Refresh 2 minutes before expiry private isStarted = false; private visibilityChangeHandler: (() => void) | null = null; + private lastRefreshTime: number | null = null; /** - * Start monitoring token and auto-refresh before expiry + * Start monitoring and auto-refresh + * @param isAuthenticated - Whether user is currently authenticated (from server state) */ - start(): void { + start(isAuthenticated: boolean = true): void { + console.log( + `[Token Refresh] start() called - isStarted: ${this.isStarted}, isAuthenticated: ${isAuthenticated}, lastRefreshTime: ${this.lastRefreshTime}` + ); + if (typeof window === "undefined") return; // Server-side bail - if (this.isStarted) return; // Already started, prevent duplicate listeners + + if (this.isStarted) { + console.log( + "[Token Refresh] Already started, skipping duplicate start()" + ); + return; // Already started, prevent duplicate listeners + } + + if (!isAuthenticated) { + console.log("[Token Refresh] Not authenticated, skipping start()"); + return; // No need to refresh if not authenticated + } this.isStarted = true; + this.lastRefreshTime = Date.now(); // Assume token was just issued + console.log( + `[Token Refresh] Manager started, lastRefreshTime set to ${this.lastRefreshTime}` + ); this.scheduleNextRefresh(); // Re-check on visibility change (user returns to tab) this.visibilityChangeHandler = () => { if (document.visibilityState === "visible") { - this.scheduleNextRefresh(); + console.log( + "[Token Refresh] Tab became visible, checking token status" + ); + this.checkAndRefreshIfNeeded(); } }; document.addEventListener("visibilitychange", this.visibilityChangeHandler); @@ -52,23 +82,85 @@ class TokenRefreshManager { } this.isStarted = false; + this.lastRefreshTime = null; // Reset refresh time on stop + } + + /** + * Reset the last refresh time (call after login or successful refresh) + */ + reset(): void { + console.log( + `[Token Refresh] reset() called - isRefreshing: ${this.isRefreshing}`, + new Error().stack?.split("\n").slice(1, 4).join("\n") // Show caller + ); + + // Don't reset if we're currently refreshing (prevents infinite loop) + if (this.isRefreshing) { + console.log("[Token Refresh] Skipping reset during active refresh"); + return; + } + + console.log( + `[Token Refresh] Resetting refresh timer, old lastRefreshTime: ${this.lastRefreshTime}` + ); + this.lastRefreshTime = Date.now(); + console.log(`[Token Refresh] New lastRefreshTime: ${this.lastRefreshTime}`); + + if (this.isStarted) { + this.scheduleNextRefresh(); + } + } + + /** + * Check if token needs refresh based on last refresh time + */ + private checkAndRefreshIfNeeded(): void { + if (!this.lastRefreshTime) { + console.log("[Token Refresh] No refresh history, refreshing now"); + this.refreshNow(); + return; + } + + const timeSinceRefresh = Date.now() - this.lastRefreshTime; + const timeUntilExpiry = ACCESS_TOKEN_EXPIRY_MS - timeSinceRefresh; + + if (timeUntilExpiry <= REFRESH_THRESHOLD_MS) { + // Token expired or about to expire - refresh immediately + console.log( + `[Token Refresh] Token likely expired (${Math.round(timeSinceRefresh / 1000)}s since last refresh), refreshing now` + ); + this.refreshNow(); + } else { + // Token still valid - reschedule + console.log( + `[Token Refresh] Token still valid (~${Math.round(timeUntilExpiry / 1000)}s remaining), rescheduling refresh` + ); + this.scheduleNextRefresh(); + } } /** * Schedule next refresh based on token expiry */ private scheduleNextRefresh(): void { - this.stop(); // Clear existing timer + // Clear existing timer but don't stop the manager + if (this.refreshTimer) { + clearTimeout(this.refreshTimer); + this.refreshTimer = null; + } - const token = getClientCookie("userIDToken"); - if (!token) { - // No token found - user not logged in, nothing to refresh + if (!this.lastRefreshTime) { + console.log("[Token Refresh] No refresh history, cannot schedule"); return; } - const timeUntilExpiry = getTimeUntilExpiry(token); - if (!timeUntilExpiry) { - console.warn("Token expired or invalid, attempting refresh now"); + const timeSinceRefresh = Date.now() - this.lastRefreshTime; + const timeUntilExpiry = ACCESS_TOKEN_EXPIRY_MS - timeSinceRefresh; + + if (timeUntilExpiry <= REFRESH_THRESHOLD_MS) { + console.warn( + "[Token Refresh] Token likely expired, attempting refresh now" + ); this.refreshNow(); return; } @@ -76,12 +168,12 @@ class TokenRefreshManager { // Schedule refresh before expiry const timeUntilRefresh = Math.max( 0, - timeUntilExpiry - this.refreshThresholdMs + timeUntilExpiry - REFRESH_THRESHOLD_MS ); console.log( - `[Token Refresh] Token expires in ${Math.round(timeUntilExpiry / 1000)}s, ` + - `scheduling refresh in ${Math.round(timeUntilRefresh / 1000)}s` + `[Token Refresh] Scheduling refresh in ${Math.round(timeUntilRefresh / 1000)}s ` + + `(~${Math.round(timeUntilExpiry / 1000)}s until expiry)` ); this.refreshTimer = setTimeout(() => { @@ -89,6 +181,16 @@ class TokenRefreshManager { }, timeUntilRefresh); } + /** + * Get rememberMe preference + * Since we can't read httpOnly cookies, we default to true and let the server + * determine the correct expiry based on the existing session + */ + private getRememberMePreference(): boolean { + // Default to true - server will use the correct expiry from the existing session + return true; + } + /** * Perform token refresh immediately */ @@ -103,14 +205,23 @@ class TokenRefreshManager { try { console.log("[Token Refresh] Refreshing access token..."); + // Preserve rememberMe state from existing session + const rememberMe = this.getRememberMePreference(); + console.log( + `[Token Refresh] Using rememberMe: ${rememberMe} (from refresh token cookie existence)` + ); + const result = await api.auth.refreshToken.mutate({ - rememberMe: false // Maintain existing rememberMe state + rememberMe }); if (result.success) { console.log("[Token Refresh] Token refreshed successfully"); - revalidateAuth(); // Refresh auth state after token refresh + this.lastRefreshTime = Date.now(); // Update refresh time this.scheduleNextRefresh(); // Schedule next refresh + + // Revalidate auth AFTER scheduling to avoid race condition + revalidateAuth(); // Refresh auth state after token refresh return true; } else { console.error("[Token Refresh] Token refresh failed:", result); @@ -141,6 +252,23 @@ class TokenRefreshManager { // Redirect to login window.location.href = "/login"; } + + /** + * Attempt immediate refresh (for page load when access token expired) + * Always attempts refresh - server will reject if no refresh token exists + * Returns true if refresh succeeded, false otherwise + * + * Note: We can't check for httpOnly refresh token from client JavaScript, + * so we always attempt and let the server decide if token exists + */ + async attemptInitialRefresh(): Promise { + console.log( + "[Token Refresh] Attempting initial refresh (server will check for refresh token)" + ); + + // refreshNow() already calls revalidateAuth() on success + return await this.refreshNow(); + } } // Singleton instance diff --git a/src/routes/index.tsx b/src/routes/index.tsx index 62d27fd..07ded4f 100644 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -164,7 +164,7 @@ export default function Home() { -
+
{ const conn = ConnectionFactory(); + console.log(`[Session] Invalidating session ${sessionId}`); await conn.execute({ sql: "UPDATE Session SET revoked = 1 WHERE id = ?", args: [sessionId] @@ -202,6 +205,9 @@ async function revokeTokenFamily( }); // Revoke all sessions in family + console.log( + `[Token Family] Revoking entire family ${tokenFamily} (reason: ${reason}). Sessions affected: ${sessions.rows.length}` + ); await conn.execute({ sql: "UPDATE Session SET revoked = 1 WHERE token_family = ?", args: [tokenFamily] @@ -255,14 +261,14 @@ async function detectTokenReuse(sessionId: string): Promise { // Grace period for race conditions (e.g., slow network, retries) if (timeSinceRotation < AUTH_CONFIG.REFRESH_TOKEN_REUSE_WINDOW_MS) { console.warn( - `Token reuse within grace period (${timeSinceRotation}ms), allowing` + `[Token Reuse] Within grace period (${timeSinceRotation}ms < ${AUTH_CONFIG.REFRESH_TOKEN_REUSE_WINDOW_MS}ms), allowing for session ${sessionId}` ); return false; } // Reuse detected outside grace period - this is a breach! console.error( - `Token reuse detected! Session ${sessionId} rotated ${timeSinceRotation}ms ago` + `[Token Reuse] BREACH DETECTED! Session ${sessionId} rotated ${timeSinceRotation}ms ago (grace period: ${AUTH_CONFIG.REFRESH_TOKEN_REUSE_WINDOW_MS}ms). Child session: ${childSession.id}` ); // Get token family and revoke entire family @@ -316,28 +322,49 @@ async function rotateRefreshToken( refreshToken: string; sessionId: string; } | null> { + console.log(`[Token Rotation] Starting rotation for session ${oldSessionId}`); + // Step 1: Validate old refresh token const oldSession = await validateRefreshToken(oldRefreshToken, oldSessionId); if (!oldSession) { - console.warn("Invalid refresh token during rotation"); + console.warn( + `[Token Rotation] Invalid refresh token during rotation for session ${oldSessionId}` + ); return null; } + console.log( + `[Token Rotation] Refresh token validated for session ${oldSessionId}` + ); + // Step 2: Detect token reuse (breach detection) const reuseDetected = await detectTokenReuse(oldSessionId); if (reuseDetected) { + console.error( + `[Token Rotation] Token reuse detected for session ${oldSessionId}` + ); // Token family already revoked by detectTokenReuse return null; } + console.log( + `[Token Rotation] No token reuse detected for session ${oldSessionId}` + ); + // Step 3: Check rotation limit if (oldSession.rotation_count >= AUTH_CONFIG.MAX_ROTATION_COUNT) { - console.warn(`Max rotation count reached for session ${oldSessionId}`); + console.warn( + `[Token Rotation] Max rotation count reached for session ${oldSessionId}` + ); await invalidateSession(oldSessionId); return null; } + console.log( + `[Token Rotation] Rotation count OK (${oldSession.rotation_count}/${AUTH_CONFIG.MAX_ROTATION_COUNT})` + ); + // Step 4: Generate new tokens const newRefreshToken = generateRefreshToken(); const refreshExpiry = rememberMe @@ -549,28 +576,42 @@ function setAuthCookies( rememberMe: boolean = false ) { // Access token cookie (short-lived, always same duration) - const accessMaxAge = getAccessCookieMaxAge(); - - setCookie(event, ACCESS_TOKEN_COOKIE_NAME, accessToken, { - maxAge: accessMaxAge, + // Session cookies (no maxAge) vs persistent cookies (with maxAge) + const accessCookieOptions: any = { path: "/", httpOnly: true, secure: env.NODE_ENV === "production", sameSite: "strict" - }); + }; - // Refresh token cookie (long-lived, varies based on rememberMe) - const refreshMaxAge = rememberMe - ? AUTH_CONFIG.REFRESH_COOKIE_MAX_AGE_LONG - : AUTH_CONFIG.REFRESH_COOKIE_MAX_AGE_SHORT; + if (rememberMe) { + // Persistent cookie - survives browser restart + accessCookieOptions.maxAge = getAccessCookieMaxAge(); + } + // else: session cookie - expires when browser closes (no maxAge) - setCookie(event, REFRESH_TOKEN_COOKIE_NAME, refreshToken, { - maxAge: refreshMaxAge, + setCookie(event, ACCESS_TOKEN_COOKIE_NAME, accessToken, accessCookieOptions); + + // Refresh token cookie (varies based on rememberMe) + const refreshCookieOptions: any = { path: "/", httpOnly: true, secure: env.NODE_ENV === "production", sameSite: "strict" - }); + }; + + if (rememberMe) { + // Persistent cookie - long-lived (90 days) + refreshCookieOptions.maxAge = getRefreshCookieMaxAge(true); + } + // else: session cookie - expires when browser closes (no maxAge) + + setCookie( + event, + REFRESH_TOKEN_COOKIE_NAME, + refreshToken, + refreshCookieOptions + ); // CSRF token for authenticated session setCSRFToken(event); @@ -613,6 +654,119 @@ async function sendEmail(to: string, subject: string, htmlContent: string) { ); } +/** + * Attempt server-side token refresh for SSR + * Called from getUserState() when access token is expired but refresh token exists + * @param event - H3Event from SSR + * @param refreshToken - Refresh token from httpOnly cookie + * @returns true if refresh succeeded, false otherwise + */ +export async function attemptTokenRefresh( + event: H3Event, + refreshToken: string +): Promise { + try { + // Step 1: Find session by refresh token hash + // (Access token may not exist if user closed browser and returned later) + const conn = ConnectionFactory(); + const tokenHash = hashRefreshToken(refreshToken); + + const sessionResult = await conn.execute({ + sql: `SELECT id, user_id, expires_at, revoked + FROM Session + WHERE refresh_token_hash = ? + AND revoked = 0`, + args: [tokenHash] + }); + + if (sessionResult.rows.length === 0) { + console.warn( + "[Token Refresh SSR] No valid session found for refresh token" + ); + return false; + } + + const session = sessionResult.rows[0]; + const sessionId = session.id as string; + + // Check if session is expired + const expiresAt = new Date(session.expires_at as string); + if (expiresAt < new Date()) { + console.warn("[Token Refresh SSR] Session expired"); + return false; + } + + // Step 2: Determine rememberMe from existing session + const now = new Date(); + const daysUntilExpiry = + (expiresAt.getTime() - now.getTime()) / (1000 * 60 * 60 * 24); + // If expires in > 30 days, assume rememberMe was true + const rememberMe = daysUntilExpiry > 30; + + // Step 3: Get client info + const clientIP = getClientIP(event); + const userAgent = getUserAgent(event); + + // Step 4: Rotate tokens + console.log(`[Token Refresh SSR] Rotating tokens for session ${sessionId}`); + const rotated = await rotateRefreshToken( + refreshToken, + sessionId, + rememberMe, + clientIP, + userAgent + ); + + if (!rotated) { + console.warn("[Token Refresh SSR] Token rotation failed"); + return false; + } + + // Step 5: Set new cookies + const accessCookieOptions: any = { + path: "/", + httpOnly: true, + secure: env.NODE_ENV === "production", + sameSite: "strict" + }; + + if (rememberMe) { + accessCookieOptions.maxAge = getAccessCookieMaxAge(); + } + + setCookie( + event, + ACCESS_TOKEN_COOKIE_NAME, + rotated.accessToken, + accessCookieOptions + ); + + const refreshCookieOptions: any = { + path: "/", + httpOnly: true, + secure: env.NODE_ENV === "production", + sameSite: "strict" + }; + + if (rememberMe) { + refreshCookieOptions.maxAge = getRefreshCookieMaxAge(true); + } + + setCookie( + event, + REFRESH_TOKEN_COOKIE_NAME, + rotated.refreshToken, + refreshCookieOptions + ); + + console.log("[Token Refresh SSR] Token refresh successful"); + return true; + } catch (error) { + console.error("[Token Refresh SSR] Error:", error); + return false; + } +} + export const authRouter = createTRPCRouter({ githubCallback: publicProcedure .input(z.object({ code: z.string() })) @@ -1405,6 +1559,9 @@ export const authRouter = createTRPCRouter({ // Reset failed attempts on successful login await resetFailedAttempts(user.id); + // Reset rate limits on successful login + await resetLoginRateLimits(email, clientIP); + // Determine token expiry based on rememberMe const accessExpiry = getAccessTokenExpiry(); // Always 15m const refreshExpiry = rememberMe @@ -2098,37 +2255,46 @@ export const authRouter = createTRPCRouter({ } // Step 6: Set new access token cookie - const accessCookieMaxAge = getAccessCookieMaxAge(); + // Session cookies (no maxAge) vs persistent cookies (with maxAge) + const accessCookieOptions: any = { + path: "/", + httpOnly: true, + secure: env.NODE_ENV === "production", + sameSite: "strict" + }; + + if (rememberMe) { + // Persistent cookie - survives browser restart + accessCookieOptions.maxAge = getAccessCookieMaxAge(); + } + // else: session cookie - expires when browser closes (no maxAge) setCookie( getH3Event(ctx), ACCESS_TOKEN_COOKIE_NAME, rotated.accessToken, - { - maxAge: accessCookieMaxAge, - path: "/", - httpOnly: true, - secure: env.NODE_ENV === "production", - sameSite: "strict" - } + accessCookieOptions ); // Step 7: Set new refresh token cookie - const refreshCookieMaxAge = rememberMe - ? AUTH_CONFIG.REFRESH_COOKIE_MAX_AGE_LONG - : AUTH_CONFIG.REFRESH_COOKIE_MAX_AGE_SHORT; + const refreshCookieOptions: any = { + path: "/", + httpOnly: true, + secure: env.NODE_ENV === "production", + sameSite: "strict" + }; + + if (rememberMe) { + // Persistent cookie - long-lived (90 days) + refreshCookieOptions.maxAge = getRefreshCookieMaxAge(true); + } + // else: session cookie - expires when browser closes (no maxAge) setCookie( getH3Event(ctx), REFRESH_TOKEN_COOKIE_NAME, rotated.refreshToken, - { - maxAge: refreshCookieMaxAge, - path: "/", - httpOnly: true, - secure: env.NODE_ENV === "production", - sameSite: "strict" - } + refreshCookieOptions ); // Step 8: Refresh CSRF token @@ -2245,6 +2411,10 @@ export const authRouter = createTRPCRouter({ maxAge: 0, path: "/" }); + setCookie(getH3Event(ctx), "csrf-token", "", { + maxAge: 0, + path: "/" + }); // Step 4: Log signout event if (userId) { diff --git a/src/server/api/routers/database.ts b/src/server/api/routers/database.ts index d015e39..54bb1a3 100644 --- a/src/server/api/routers/database.ts +++ b/src/server/api/routers/database.ts @@ -413,7 +413,7 @@ export const databaseRouter = createTRPCRouter({ await conn.execute(tagQuery); } - cache.deleteByPrefix("blog-"); + await cache.deleteByPrefix("blog-"); return { data: results.lastInsertRowid }; } catch (error) { @@ -529,7 +529,7 @@ export const databaseRouter = createTRPCRouter({ await conn.execute(tagQuery); } - cache.deleteByPrefix("blog-"); + await cache.deleteByPrefix("blog-"); return { data: results.lastInsertRowid }; } catch (error) { @@ -565,7 +565,7 @@ export const databaseRouter = createTRPCRouter({ args: [input.id] }); - cache.deleteByPrefix("blog-"); + await cache.deleteByPrefix("blog-"); return { success: true }; } catch (error) { diff --git a/src/server/cache.ts b/src/server/cache.ts index 32e1351..103ceb7 100644 --- a/src/server/cache.ts +++ b/src/server/cache.ts @@ -1,77 +1,166 @@ -import { CACHE_CONFIG } from "~/config"; +/** + * Redis-backed Cache for Serverless + * + * Uses Redis for persistent caching across serverless invocations. + * Redis provides: + * - Fast in-memory storage + * - Built-in TTL expiration (automatic cleanup) + * - Persistence across function invocations + * - Native support in Vercel and other platforms + */ -interface CacheEntry { - data: T; - timestamp: number; +import { createClient } from "redis"; +import { env } from "~/env/server"; + +let redisClient: ReturnType | null = null; +let isConnecting = false; +let connectionError: Error | null = null; + +/** + * Get or create Redis client (singleton pattern) + */ +async function getRedisClient() { + if (redisClient && redisClient.isOpen) { + return redisClient; + } + + if (isConnecting) { + // Wait for existing connection attempt + await new Promise((resolve) => setTimeout(resolve, 100)); + return getRedisClient(); + } + + if (connectionError) { + throw connectionError; + } + + try { + isConnecting = true; + redisClient = createClient({ url: env.REDIS_URL }); + + redisClient.on("error", (err) => { + console.error("Redis Client Error:", err); + connectionError = err; + }); + + await redisClient.connect(); + isConnecting = false; + connectionError = null; + return redisClient; + } catch (error) { + isConnecting = false; + connectionError = error as Error; + console.error("Failed to connect to Redis:", error); + throw error; + } } -class SimpleCache { - private cache: Map> = new Map(); +/** + * Redis-backed cache interface + */ +export const cache = { + async get(key: string): Promise { + try { + const client = await getRedisClient(); + const value = await client.get(key); - get(key: string, ttlMs: number): T | null { - const entry = this.cache.get(key); - if (!entry) return null; + if (!value) { + return null; + } - const now = Date.now(); - if (now - entry.timestamp > ttlMs) { - this.cache.delete(key); + return JSON.parse(value) as T; + } catch (error) { + console.error(`Cache get error for key "${key}":`, error); return null; } + }, - return entry.data as T; - } + async set(key: string, data: T, ttlMs: number): Promise { + try { + const client = await getRedisClient(); + const value = JSON.stringify(data); - getStale(key: string): T | null { - const entry = this.cache.get(key); - return entry ? (entry.data as T) : null; - } + // Redis SET with EX (expiry in seconds) + await client.set(key, value, { + EX: Math.ceil(ttlMs / 1000) + }); + } catch (error) { + console.error(`Cache set error for key "${key}":`, error); + } + }, - has(key: string): boolean { - return this.cache.has(key); - } + async delete(key: string): Promise { + try { + const client = await getRedisClient(); + await client.del(key); + } catch (error) { + console.error(`Cache delete error for key "${key}":`, error); + } + }, - set(key: string, data: T): void { - this.cache.set(key, { - data, - timestamp: Date.now() - }); - } + async deleteByPrefix(prefix: string): Promise { + try { + const client = await getRedisClient(); + const keys = await client.keys(`${prefix}*`); - clear(): void { - this.cache.clear(); - } - - delete(key: string): void { - this.cache.delete(key); - } - - deleteByPrefix(prefix: string): void { - for (const key of this.cache.keys()) { - if (key.startsWith(prefix)) { - this.cache.delete(key); + if (keys.length > 0) { + await client.del(keys); } + } catch (error) { + console.error( + `Cache deleteByPrefix error for prefix "${prefix}":`, + error + ); + } + }, + + async clear(): Promise { + try { + const client = await getRedisClient(); + await client.flushDb(); + } catch (error) { + console.error("Cache clear error:", error); + } + }, + + async has(key: string): Promise { + try { + const client = await getRedisClient(); + const exists = await client.exists(key); + return exists === 1; + } catch (error) { + console.error(`Cache has error for key "${key}":`, error); + return false; } } -} +}; -export const cache = new SimpleCache(); +/** + * Execute function with Redis caching + */ export async function withCache( key: string, ttlMs: number, fn: () => Promise ): Promise { - const cached = cache.get(key, ttlMs); + const cached = await cache.get(key); if (cached !== null) { return cached; } const result = await fn(); - cache.set(key, result); + await cache.set(key, result, ttlMs); return result; } /** - * Returns stale data if fetch fails, with optional stale time limit + * Execute function with Redis caching and stale data fallback + * + * Strategy: + * 1. Try to get fresh cached data (within TTL) + * 2. If not found, execute function + * 3. If function fails, try to get stale data (ignore TTL) + * 4. Store result with TTL for future requests */ export async function withCacheAndStale( key: string, @@ -82,36 +171,36 @@ export async function withCacheAndStale( logErrors?: boolean; } = {} ): Promise { - const { maxStaleMs = CACHE_CONFIG.MAX_STALE_DATA_MS, logErrors = true } = - options; + const { maxStaleMs = 7 * 24 * 60 * 60 * 1000, logErrors = true } = options; - const cached = cache.get(key, ttlMs); + // Try fresh cache + const cached = await cache.get(key); if (cached !== null) { return cached; } try { + // Execute function const result = await fn(); - cache.set(key, result); + await cache.set(key, result, ttlMs); + // Also store with longer TTL for stale fallback + const staleKey = `${key}:stale`; + await cache.set(staleKey, result, maxStaleMs); return result; } catch (error) { if (logErrors) { console.error(`Error fetching data for cache key "${key}":`, error); } - const stale = cache.getStale(key); - if (stale !== null) { - const entry = (cache as any).cache.get(key); - const age = Date.now() - entry.timestamp; + // Try stale cache with longer TTL key + const staleKey = `${key}:stale`; + const staleData = await cache.get(staleKey); - if (age <= maxStaleMs) { - if (logErrors) { - console.log( - `Serving stale data for cache key "${key}" (age: ${Math.round(age / 1000 / 60)}m)` - ); - } - return stale; + if (staleData !== null) { + if (logErrors) { + console.log(`Serving stale data for cache key "${key}"`); } + return staleData; } throw error; diff --git a/src/server/security.ts b/src/server/security.ts index a85689d..45adb66 100644 --- a/src/server/security.ts +++ b/src/server/security.ts @@ -200,9 +200,11 @@ export async function clearRateLimitStore(): Promise { } /** - * Cleanup expired rate limit entries every 5 minutes + * Opportunistic cleanup of expired rate limit entries + * Called probabilistically during rate limit checks (serverless-friendly) + * Note: setInterval is not reliable in serverless environments */ -setInterval(async () => { +async function cleanupExpiredRateLimits(): Promise { try { const { ConnectionFactory } = await import("./database"); const conn = ConnectionFactory(); @@ -212,9 +214,10 @@ setInterval(async () => { args: [now] }); } catch (error) { + // Silent fail - cleanup is opportunistic console.error("Failed to cleanup expired rate limits:", error); } -}, RATE_LIMIT_CLEANUP_INTERVAL_MS); +} /** * Get client IP address from request headers @@ -274,6 +277,11 @@ export async function checkRateLimit( const now = Date.now(); const resetAt = new Date(now + windowMs); + // Opportunistic cleanup (10% chance) - serverless-friendly + if (Math.random() < 0.1) { + cleanupExpiredRateLimits().catch(() => {}); // Fire and forget + } + const result = await conn.execute({ sql: "SELECT id, count, reset_at FROM RateLimit WHERE identifier = ?", args: [identifier] @@ -506,6 +514,22 @@ export async function resetFailedAttempts(userId: string): Promise { }); } +/** + * Reset login rate limits on successful login + */ +export async function resetLoginRateLimits( + email: string, + clientIP: string +): Promise { + const { ConnectionFactory } = await import("./database"); + const conn = ConnectionFactory(); + + await conn.execute({ + sql: "DELETE FROM RateLimit WHERE identifier IN (?, ?)", + args: [`login:ip:${clientIP}`, `login:email:${email}`] + }); +} + export const PASSWORD_RESET_CONFIG = CONFIG_PASSWORD_RESET; /**