From 88835b6d06e3ba6e9fe9a67cd7f1030bd344b99d Mon Sep 17 00:00:00 2001 From: saran Date: Sat, 10 Oct 2026 14:31:33 +0530 Subject: [PATCH 01/12] excluded cache files and added seed sources --- .gitignore | 25 ++++++++++++++++++ .../__pycache__/__init__.cpython-314.pyc | Bin 152 -> 0 bytes .../__pycache__/canonical.cpython-314.pyc | Bin 5168 -> 0 bytes .../__pycache__/config.cpython-314.pyc | Bin 3122 -> 0 bytes apps/ingestion/__pycache__/db.cpython-314.pyc | Bin 1759 -> 0 bytes .../__pycache__/language.cpython-314.pyc | Bin 2273 -> 0 bytes .../__pycache__/metrics.cpython-314.pyc | Bin 3713 -> 0 bytes .../__pycache__/seed_sources.cpython-314.pyc | Bin 2979 -> 0 bytes .../__pycache__/service.cpython-314.pyc | Bin 8225 -> 0 bytes .../__pycache__/sweeper.cpython-314.pyc | Bin 1807 -> 0 bytes .../__pycache__/upsert.cpython-314.pyc | Bin 3989 -> 0 bytes .../cli/__pycache__/__init__.cpython-314.pyc | Bin 156 -> 0 bytes .../__pycache__/story_browser.cpython-314.pyc | Bin 4594 -> 0 bytes .../__pycache__/__init__.cpython-314.pyc | Bin 163 -> 0 bytes .../__pycache__/apply.cpython-314.pyc | Bin 9275 -> 0 bytes .../__pycache__/decide.cpython-314.pyc | Bin 5361 -> 0 bytes .../__pycache__/__init__.cpython-314.pyc | Bin 158 -> 0 bytes .../embed/__pycache__/input.cpython-314.pyc | Bin 3021 -> 0 bytes .../embed/__pycache__/model.cpython-314.pyc | Bin 6166 -> 0 bytes .../__pycache__/__init__.cpython-314.pyc | Bin 158 -> 0 bytes .../fetch/__pycache__/guard.cpython-314.pyc | Bin 8960 -> 0 bytes .../fetch/__pycache__/rss.cpython-314.pyc | Bin 7266 -> 0 bytes .../jobs/__pycache__/__init__.cpython-314.pyc | Bin 157 -> 0 bytes .../jobs/__pycache__/baseline.cpython-314.pyc | Bin 2644 -> 0 bytes .../__pycache__/maintenance.cpython-314.pyc | Bin 1703 -> 0 bytes .../__pycache__/ratings_sync.cpython-314.pyc | Bin 4826 -> 0 bytes apps/ingestion/language.py | 11 +++++--- apps/ingestion/requirements.txt | 2 +- apps/ingestion/seed_sources.py | 18 +++++++++++++ .../__pycache__/__init__.cpython-314.pyc | Bin 158 -> 0 bytes .../conftest.cpython-314-pytest-8.3.3.pyc | Bin 1148 -> 0 bytes ...test_baseline.cpython-314-pytest-8.3.3.pyc | Bin 4263 -> 0 bytes ...cluster_apply.cpython-314-pytest-8.3.3.pyc | Bin 22210 -> 0 bytes .../test_config.cpython-314-pytest-8.3.3.pyc | Bin 6302 -> 0 bytes .../test_db.cpython-314-pytest-8.3.3.pyc | Bin 4216 -> 0 bytes ...t_embed_input.cpython-314-pytest-8.3.3.pyc | Bin 10412 -> 0 bytes ...t_embed_model.cpython-314-pytest-8.3.3.pyc | Bin 9933 -> 0 bytes ...t_fetch_guard.cpython-314-pytest-8.3.3.pyc | Bin 15269 -> 0 bytes ...test_language.cpython-314-pytest-8.3.3.pyc | Bin 5907 -> 0 bytes ...t_maintenance.cpython-314-pytest-8.3.3.pyc | Bin 7023 -> 0 bytes .../test_metrics.cpython-314-pytest-8.3.3.pyc | Bin 6952 -> 0 bytes ..._ratings_sync.cpython-314-pytest-8.3.3.pyc | Bin 8018 -> 0 bytes .../test_rss.cpython-314-pytest-8.3.3.pyc | Bin 18977 -> 0 bytes ...est_scheduler.cpython-314-pytest-8.3.3.pyc | Bin 9423 -> 0 bytes ...cheduler_jobs.cpython-314-pytest-8.3.3.pyc | Bin 1671 -> 0 bytes ..._seed_sources.cpython-314-pytest-8.3.3.pyc | Bin 4279 -> 0 bytes .../test_service.cpython-314-pytest-8.3.3.pyc | Bin 13051 -> 0 bytes ...story_browser.cpython-314-pytest-8.3.3.pyc | Bin 6483 -> 0 bytes .../test_sweeper.cpython-314-pytest-8.3.3.pyc | Bin 7806 -> 0 bytes .../test_upsert.cpython-314-pytest-8.3.3.pyc | Bin 11722 -> 0 bytes apps/ingestion/tests/conftest.py | 17 +++++++++++- apps/ingestion/tests/test_language.py | 11 +++++--- apps/ingestion/tests/test_seed_sources.py | 4 +-- .../__pycache__/__init__.cpython-314.pyc | Bin 160 -> 0 bytes .../__pycache__/scheduler.cpython-314.pyc | Bin 7345 -> 0 bytes .../__pycache__/__init__.cpython-314.pyc | Bin 149 -> 0 bytes .../test_migrate.cpython-314-pytest-8.3.3.pyc | Bin 4739 -> 0 bytes 57 files changed, 78 insertions(+), 10 deletions(-) delete mode 100644 apps/ingestion/__pycache__/__init__.cpython-314.pyc delete mode 100644 apps/ingestion/__pycache__/canonical.cpython-314.pyc delete mode 100644 apps/ingestion/__pycache__/config.cpython-314.pyc delete mode 100644 apps/ingestion/__pycache__/db.cpython-314.pyc delete mode 100644 apps/ingestion/__pycache__/language.cpython-314.pyc delete mode 100644 apps/ingestion/__pycache__/metrics.cpython-314.pyc delete mode 100644 apps/ingestion/__pycache__/seed_sources.cpython-314.pyc delete mode 100644 apps/ingestion/__pycache__/service.cpython-314.pyc delete mode 100644 apps/ingestion/__pycache__/sweeper.cpython-314.pyc delete mode 100644 apps/ingestion/__pycache__/upsert.cpython-314.pyc delete mode 100644 apps/ingestion/cli/__pycache__/__init__.cpython-314.pyc delete mode 100644 apps/ingestion/cli/__pycache__/story_browser.cpython-314.pyc delete mode 100644 apps/ingestion/clustering/__pycache__/__init__.cpython-314.pyc delete mode 100644 apps/ingestion/clustering/__pycache__/apply.cpython-314.pyc delete mode 100644 apps/ingestion/clustering/__pycache__/decide.cpython-314.pyc delete mode 100644 apps/ingestion/embed/__pycache__/__init__.cpython-314.pyc delete mode 100644 apps/ingestion/embed/__pycache__/input.cpython-314.pyc delete mode 100644 apps/ingestion/embed/__pycache__/model.cpython-314.pyc delete mode 100644 apps/ingestion/fetch/__pycache__/__init__.cpython-314.pyc delete mode 100644 apps/ingestion/fetch/__pycache__/guard.cpython-314.pyc delete mode 100644 apps/ingestion/fetch/__pycache__/rss.cpython-314.pyc delete mode 100644 apps/ingestion/jobs/__pycache__/__init__.cpython-314.pyc delete mode 100644 apps/ingestion/jobs/__pycache__/baseline.cpython-314.pyc delete mode 100644 apps/ingestion/jobs/__pycache__/maintenance.cpython-314.pyc delete mode 100644 apps/ingestion/jobs/__pycache__/ratings_sync.cpython-314.pyc delete mode 100644 apps/ingestion/tests/__pycache__/__init__.cpython-314.pyc delete mode 100644 apps/ingestion/tests/__pycache__/conftest.cpython-314-pytest-8.3.3.pyc delete mode 100644 apps/ingestion/tests/__pycache__/test_baseline.cpython-314-pytest-8.3.3.pyc delete mode 100644 apps/ingestion/tests/__pycache__/test_cluster_apply.cpython-314-pytest-8.3.3.pyc delete mode 100644 apps/ingestion/tests/__pycache__/test_config.cpython-314-pytest-8.3.3.pyc delete mode 100644 apps/ingestion/tests/__pycache__/test_db.cpython-314-pytest-8.3.3.pyc delete mode 100644 apps/ingestion/tests/__pycache__/test_embed_input.cpython-314-pytest-8.3.3.pyc delete mode 100644 apps/ingestion/tests/__pycache__/test_embed_model.cpython-314-pytest-8.3.3.pyc delete mode 100644 apps/ingestion/tests/__pycache__/test_fetch_guard.cpython-314-pytest-8.3.3.pyc delete mode 100644 apps/ingestion/tests/__pycache__/test_language.cpython-314-pytest-8.3.3.pyc delete mode 100644 apps/ingestion/tests/__pycache__/test_maintenance.cpython-314-pytest-8.3.3.pyc delete mode 100644 apps/ingestion/tests/__pycache__/test_metrics.cpython-314-pytest-8.3.3.pyc delete mode 100644 apps/ingestion/tests/__pycache__/test_ratings_sync.cpython-314-pytest-8.3.3.pyc delete mode 100644 apps/ingestion/tests/__pycache__/test_rss.cpython-314-pytest-8.3.3.pyc delete mode 100644 apps/ingestion/tests/__pycache__/test_scheduler.cpython-314-pytest-8.3.3.pyc delete mode 100644 apps/ingestion/tests/__pycache__/test_scheduler_jobs.cpython-314-pytest-8.3.3.pyc delete mode 100644 apps/ingestion/tests/__pycache__/test_seed_sources.cpython-314-pytest-8.3.3.pyc delete mode 100644 apps/ingestion/tests/__pycache__/test_service.cpython-314-pytest-8.3.3.pyc delete mode 100644 apps/ingestion/tests/__pycache__/test_story_browser.cpython-314-pytest-8.3.3.pyc delete mode 100644 apps/ingestion/tests/__pycache__/test_sweeper.cpython-314-pytest-8.3.3.pyc delete mode 100644 apps/ingestion/tests/__pycache__/test_upsert.cpython-314-pytest-8.3.3.pyc delete mode 100644 apps/ingestion/workers/__pycache__/__init__.cpython-314.pyc delete mode 100644 apps/ingestion/workers/__pycache__/scheduler.cpython-314.pyc delete mode 100644 infra/tests/__pycache__/__init__.cpython-314.pyc delete mode 100644 infra/tests/__pycache__/test_migrate.cpython-314-pytest-8.3.3.pyc diff --git a/.gitignore b/.gitignore index 0b9faa9..6755daa 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ *.DS_Store .idea/ +.vscode/ docs/ validation/ @@ -7,3 +8,27 @@ validation/ .claude/ CLAUDE.md + +# backend (Python) +apps/ingestion/models/*.bin +__pycache__/ +*.py[cod] +.pytest_cache/ +.venv/ +venv/ +*.egg-info/ +.mypy_cache/ +.ruff_cache/ + +# frontend (apps/web, not built yet but planned) +node_modules/ +dist/ +build/ +.next/ +.turbo/ +*.local + +# env/secrets +.env +.env.* +!.env.example diff --git a/apps/ingestion/__pycache__/__init__.cpython-314.pyc b/apps/ingestion/__pycache__/__init__.cpython-314.pyc deleted file mode 100644 index 2acf40ec635e5b8fe1988df66db662bc102cfc3d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 152 zcmdPq_Z$PFY2 diff --git a/apps/ingestion/__pycache__/canonical.cpython-314.pyc b/apps/ingestion/__pycache__/canonical.cpython-314.pyc deleted file mode 100644 index b50aff2023c4817a652046895c87e5a3ddb4fcd4..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5168 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 diff --git a/apps/ingestion/__pycache__/config.cpython-314.pyc b/apps/ingestion/__pycache__/config.cpython-314.pyc deleted file mode 100644 index ae03186da1bde5d631f91ef936e43b222068a17b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3122 zcmds3PfQe77=Lf}@9eUJhyo(YD7AF!!irVETAS!DA`K#rfHpxV!|ni6hMDEf4A!PG zduUQ?Of9*O`DjW)Lu+_!P3o=2?rBoe-3hy5Q28&ug4#}=R@cjW!&Z|b(a5tP8JEsL|2f2d$5U}H)ArFalhYP zUViUGm%T@Dk?#+e$KBqMQm4Pd9Vx%BFtPeFCor*f`^~`RfZ*9M5`+=&h7lGckW zLWSV3MM9t!soPX+hJ}a_1fDJN6+#HOFy+jMfSXX1vwj8{dUYvn@aDM8zkl*hiMX_;sSpHeNul!>B^@fnFolQJ+yjF%@z(0M$L1w0Hh#U?PrjN6YhO=!;ZJLT;F zh-85Fwx7tRMYK7__JB!jZ(2#2QEdB1buuZL2EBB@{Z@R?fc0?$?5V{s>SQ9W$yW_i zN=?M2OvZ?Vv1Nl=GhS4wHFM3b5JgGTbW<{AQ6$^oAP)WpMN$^6AO`PyLCM|3bNc%J zh{G+zEOOMP9oho$chvn3I$1RAngNv+8$uP}35Pe}QosV!QBwUl7O}-x9uc`H&){J% zJ)E}9y=4fy-eP$yM)1(*NXh#eQ4`|PLDXD40FA*t4M<@c(H_+3Jc6eQ9rx@(3ZC<> z`dY6^lj^F^GDllF=dc}=H9!KGtS{=dy?RF0Y%b9|cwwMR=;`YglRbj%r8{iakVa)u z1<21agoVp&JZey#Ugt?AWozi1SVoUnv_9Djla8^EK_`or*vKs{*PPomdtkotP@X$< zzoWn=@@(S4)KX>j?dvzM&qxcE{E7#Kk{D*-x4Pm_|ef@(2 z6c&W~4%tmf1CR|+sCA&QE_k?DXo8iFCCgaQa~-+p?9q9?Ezh;x?=G;(Jez!sU&cZg zU?B;=1va@#8sV6&13=V6f^eW6Cv4(MtcJL>R$(-vOOR~-J4W_?T8xmdSk+!!74=&`fLX?5 z4N`l`iKsD}W}RH?=ImyJ*RN_$-gVPAEdvxStxPLvOH%m>`PwG={OwRP&C%EBw7XFn zQDq*=g0-_D-=CIL)!;`U?eV70mt^53Ro5Wvk2+R~!bVTG{ql#3VH&pI%A_HcMt$S}-M5oewr!X{jGX$t~+;ZwrhUh;e7SsZ0};Ax)7*;5~wc(8h!~h zEJdpB#Aa&?k+yuK?cVvNx`wZ~FS*>*LS3{_7oV?-&k8qs;3V$w(;#;{@>%4L@@tS^ z3|D2(u2jPLaP{g+5T@aTRihS!`?~g2hwtI>eTk6oQAaotV1EjQ6ZI^VE6j8orE*)8 zvjd`-)>EQr2dFhvWyA@awzr?!CMb%uDxB(~kvf&j!6iX{1hd|hK&2Jk;6rb7kS8OMB+M*l?WWoKwB4jI)v?WxuAqi^KDsnbSxg=gKyOU%SXV;w> zHzX3sMU{#;00#~g>Jf?dKj;y0;z(%`Xep>txNvKTARKvbytY$;`lNmH=IhOS@ArQD zYEO5TfXx2(-P$i2Av-u}Ng_7(J_W-e4Ptfm5t%&Gvw=xpM;0JYrY7)3{-<$ArnF2xm0ENWcA8>h-LyaOPL$$I z)_K5jQle1`DzxE9fqq|(OmjgnE==KYCotcI)Z4F{POTZurCzPz|zA60~<0)fYo**n?%_qH0MR43)+t7!A@1dHEda5Y(fh?wwbn@r4l7B5hNKdEr@=S~RkooNnaLVgdjoq%9MF$bkSl-ku?F86u z$Y*!~rX?+U-o|h}AIaxbTdaeL0ODZ`&w?G}73ddXr*w|)D!qT0&|^Q4;*Ae)C4Nlz z-zUXCkKnFKwA9T9;6{|TZ7+0fJJLeYEO0(Qngjx z2O*+M)tMJ!zMb0$y?T|s#gD^|it`*8|2)wtrGIM}%?|0>Aw9p6laEuGn`_tCZU)zb g&nw>_UwV+LH1x*<#YXZ0DgKp$|IB_5^s$QmUmkRUPyhe` diff --git a/apps/ingestion/__pycache__/language.cpython-314.pyc b/apps/ingestion/__pycache__/language.cpython-314.pyc deleted file mode 100644 index eefc66bd11660dd1e944b325d39d7386839dd30e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2273 zcmbUiOKclObjE)>**MOpNk3}0aY*YniKP!Ls=1ld(SX6rggxLIxNO|s2)cbQ$c zO@v^99s&}gJ)nk<1QJNJqK6(iaHu%JEg`954MnO{IB-ENIpoTlU9S_$jVH~#H*aR% zy#JhR;$jG3C-(ED>j8uwvxNtKm%!UU0bwHnS!f0cIEynr%Xfs)zO3K!1NLVFnV=N} z-f=4=1g!8yf3DdRvx|?Si_rn>u5<0Xe2*Ha5xIRHXBP{>-iAG^(B75RDELfH2zMZ> zNoW)z9Y~0FAhlV=R$Pd|s)4PnX2F+4DX#Jv$QhTk`HD0z^JUqROO~Q*{PAI7B$LgL zjpQ!~WBiZbo=%bx;CejWvwLh@RX`+^L?_WS71le$z@4ZmgO>U!At!qOdBLk z(=CZAB5DLNq0*JR~fv{1rzBiXd z=nxAY%3=c*P>!8^28v;iQH*$$7dQ`_dt@fj9Xy!|(vaw=MI+2_%o-X~b*WtA`b~?_ z*u>Q6*tnP*&Sz4XFzF`C2%er!Uod52rcH@RT6$V1i)l^1YFbihF)bN}nFg23Ci6qu zb8)}1L}TK-Y`G5*4g|%SL@*I-{yogS_x7)mj%s56R-$`7(f!T&y9d4&Hxj2;LJu1f z_QWgkHl7Sipi$@WTiQzO*0GDfp)kj?w_L zmatl)aW^MKPfHpivQ;749R#5a&9QJ>j?G=9k)3Z8#ag^&Q6wz&J7AbSFxhAaMeqcE z>ddRjCl0>nck%rZmrt7DXbDuTLp}JNU>K|1oM&MD2x6h%d#ASJ)u0zG0GL1kXklRzm-hRP91CAYJjs&<0m=p@Z8Pt2g z<*+%&g8K}xPz=;=8R0_c-#3g%AyQ|d=tOo>9LWp|=ba>@VFOBCDOog3WJ8roGHsbEj0r+^bWF@= zgt7CPsqxWNlqBG70v`swUcQb>8e;?f&w_rzbw|-AEj>&piysR?fWt&KLVP!(G+* z;kC%&YGU6niLMWVcVpi*+QZeh_FLJT+0{$8hi_yz+WOy}uwSjl+iyr8Ep5g5^*I04 z!P~PN@l$pN?%jCvlTQ0wwd3gKbAy{5gDXR8f${G}nA~LiKQ$E)f}hXHV@p)#glHY~vVA ztmH6$9ZHVPx1r=1zMpmRnjpFxu9>ymgKG#e8gd_RvkJD+G=34V+c*T2gntt<&NoTh zD%yY5CyshP(hFBeA512b*3;t#y9NFh_&Y>%Q87d!OQx=o=V9N`nHdZ5ZtdP|eQ9N2 zEik<{JPos>A7NFRO3<(NY6)v%KH+A)jOhX2IcA7<4H52w*ICbHqp`)8)k*kE$|-%=UjGMT)%~;p diff --git a/apps/ingestion/__pycache__/metrics.cpython-314.pyc b/apps/ingestion/__pycache__/metrics.cpython-314.pyc deleted file mode 100644 index 060739acbd2854fb2c33b9a4d3ea6db33dbf9f9d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3713 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 diff --git a/apps/ingestion/__pycache__/seed_sources.cpython-314.pyc b/apps/ingestion/__pycache__/seed_sources.cpython-314.pyc deleted file mode 100644 index ea6471888f6201308c4dccd565f925fcba96f043..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2979 zcmaJ@O>Er86&^14U%UFVB3G8=9Z7a9#_J`l3U!>M4rE!jRI4kQTFGf4hoH#OE^RJJ zaLCHyX%B57^imfM&;|$)9}3hxHAi0pBscd`Y|uL15J3vm?IAY_ZeXD3p>N1t{RQn1 z9KLz)&3iNNee;IryYpED?bgSCy8E*Erzi~HyCozU3UW!WzDJi9-w3LyuQr9K0%33JRN%?(gx72e?+AH<#OEXgcG3g0u zKzc42F3px|110)6s%a0roc$zQTm}dtfe^5pPGgE*o_HWklsOAe(%I*6M}odn5fn?X*s zJkxcyW!2J1(4$(gC#6bchg4y;LE2Evst1f9&y2||-o9m$Af;%E4RH-pU{&@U!|$$u zjI!V_n3}Hm!%UZ2o3K4yDTRH=3AdU%EcD;#?B_7|*c1``uq9B}vf*`fLQ86$`u!}L zp+^(X-M1Y=soxiRGf6aL4*f#M#kWLZlte#$_3A7xL-_pE#)cZ@Sv9N;#R&M- z%B=qyGXh}`*HHpB9rQ}xy(sKkmSbpanWg)6!|$Il@DCK9KsfQ7Sv7Q8^Ye;vH)2Kh zZ_W5ZXI(6nN5mpnVb(;&FqlPUkV7cDP+pmnDtMt>SprmgjtU{4WEm~uD5FJuIHPQI zB%?*#$!HND$!HO`Gg`#$j0)McnZ?z)6+C%1^dQh8KHE7SA${0WBt7OWVs<=6qa$8{ zmBsCLcCx*KNUyUBDr66xPuy7mzP5zROO^SB@{OztkwNTbEe}qKiMnq-jHAp(d^&B(cb<_O= zg2SBLw4A!)Qs!?wxGb(x;!u$)j$(?pEhhqltEl%x#kQ#kb%32`%M@8{lG`~e+1o)* zmfLlK$g(p6<|g2!!_YO*OO)gW_E^{K8QW#whiW){0b&$xm=Ub8iXLwGDv*)IY(3|`y(m5ga zHPb2!i6FJIv?|Tctpr^zM4l>9wo>FiQ9YLg?Smq(+*%CMP=`aP3UOx;rUz-=grvDd z3zAVj0$%kT8VA{{07i$UAc(VkIg>z6z)NpK*FdeF-p2gj`iFLst$g3J{OD)-(P#O> z(|n=TKlq#CuZkb|kG}Vp{;8eWR&L`6;N?bLKno#P^6t9 zAO+4j1)m76;m$&+gQ!5cq?Z0y135pMhwg1B@-Vad4oD z&=^{ecgD;6nCWm3{JlurrS`mUqt8a`nU3xnnrk=keD7TiGeljX?Cr z+{{4Y(}Av;GnpVQ%gl`|3td41{S%EpN8Qg+W-o~n znVowNOOM^=stl*)q4`*E^)3D@5lfW0P}=N1-8^@#xpKQTH1SCLq}2Rzt=0EKrm)me z7;m1x@}#maS6h8kOg7QSRqZ>aJ?)<;UL<8ag diff --git a/apps/ingestion/__pycache__/service.cpython-314.pyc b/apps/ingestion/__pycache__/service.cpython-314.pyc deleted file mode 100644 index 0bb6944d516993d6c6671682add29a39af31f723..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 8225 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 diff --git a/apps/ingestion/__pycache__/sweeper.cpython-314.pyc b/apps/ingestion/__pycache__/sweeper.cpython-314.pyc deleted file mode 100644 index b9f38fd7fd8decf4b5c8b0afa3e799451db3242d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1807 zcmZ`(O>7%Q6rQ!$vAu56B&CXk3Z4ECyuyuXaY`s@QAr$Z)lJg1;}A7;wcQ=ZOV+!q znQ`3osydkoGTt&_x>J=o-?bvQ&!k*ij+J%5fftIbN1a37$BL zG?@=+Ni8vfw4n*)j5-oeYOxbYO|^dl5RGuUjai2fpHQ5=sLhqiwc=cDMJwXpzMoO2 z@Um@gGTipDPh87(H?TpuZ90VEj?J5}OHV~RnB4F^m$=+E9Ob%UZhDOd&YsTUGdWzy z;h7vheHOQDH-~wXxOl_ZB7m!6!?iH=+8iK>VKzYvs||pro=F(fBlxU3rAk4@a9xiZ z-1c0y2rb7etWy8J94Wwlg< zuu1DWu`FWgFP_BZDxNP_Yvo0N4NtwORWQD|INv|18=RAt&l#S@(~*L0+P+SS!8}(0 zEv{55k)~Vs%cb zlrNWS_^qsCw`{I5#e^q8;u9kUAJnn5p9P_d>gZZ*<_m-@*gJx*NKHwTw3rsxWG$hI z%1z~EsMhr37?=k#WZk_v?z3&v^EamEU?uMM++YmSxb4`GG+#Ubb=l3dSYBnEeikG` zSAsZ17YtFt+tdwY%QpFs2x@4k;n9}CnQ-xVP{^+^LRp>})Nu2w9^K5lq{BEAbl&iN zmIo3LCN9lKyqWU1g9ExARVLAOdJvE%;A8K?Ul%<^DIn>S@V_^Dd@mG!-tN!8AL`G& z)vJAreK>>6i0eW|Y52mNJpe)EYT+Imr6DTnBS5MgC=u^<+X@mPZ-ZpSTPhq3 z(sPxSY7HnmAB;pVq5IISh!ugg8x1Xfr{&x>_tQ>(wijAk`q;Y#NS3 zMZSZ$LtIs%Lm-ED^u3X(*I_~W85TVxY=>WJ5cD;1=CDvCp_iFni!jm1CnqOEs$xt> zgfbC&Mvs9U7RVL|U9_7yczgEC**oc-PkvbGop|qFrf@%V{%+>{z0615`Q6d6Zuzh2 z5ALQ;-%@s_AB-Q{Ir;0#{hHB(pKk0gi4wa-^$-gt>-)#PwIktP?@V8@MFMd^i zl169G?I~#ZjXUPf$?xo*I{S;=%anR@iH^e6s`S_rQz}U6y5*U=9w@rrXahq;9fzb# zMsy2CO{dL(TR?y*V1lz9CXK!h0AWD%fRF@iKi1T2%d^@Jxj;n)iKk{l*o#Y&^du%p bYtkch{2>~7gmQawW?1f~pB{Nmlo04&Vf^Wz diff --git a/apps/ingestion/__pycache__/upsert.cpython-314.pyc b/apps/ingestion/__pycache__/upsert.cpython-314.pyc deleted file mode 100644 index 385824c9244343ba5dd2bd4efadeecf5985c2be1..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3989 zcmb_fO>7&-6`m!Re@lw`i!3`bt&wH9+?Wv)TZYrRPOC^v#D=EiB_+A7<;{AzlC~mu z+1X)i`J^HN9HRwv=^^boKv4A3V~%Z4?V*4RU5G^k7zldEjfPSP&8cr@xztjXd+HD~ z^X9#o+4tUj^WN}EdO$)@zE1u1XAe^deZ>ZTiFkx?*aTt^DTt!`ND+#{Qiz5o867Hy zmqaRZI$Vq_MQDW6VllcDqcKiLig6lOqBOA~j-e@}#Kw^lp9^_=&vm~=B_#oTX&lvu zXi|wxBRSa$3kX%;#I2@d5Q?+&%ED5yT3D#ADh2%K&#%j~c-bxQHH^DyX2OkJLp1vjZ#mJN2o*BuxpY8ut_ zy5_J!90mq8L4BZ?n{19&FKhpX)02wjNY1G=D@_4e8hmmf}K7zV7V-<&6idS z6`Y+pworC*aJbw`j|9^^qR-kBtP>V{Qf4(!Jxyk^T@QF-&t8x2+3XE{yS-7@c5e{0 zA2@-{b^F*C-G#^g2u>{(<}20g!t#8nP+2H|@hA4gmi;g*%K%2+%hGZgFD#eeEfp84 zeOT!C2wq&qt1FB1)dH>*s-PHmBEE$ScNa>li-pD6W9$UE96QWcL1rIff`R~uJO>7a z{{^9-u;=3(_Wc_a`#k2-uE&GoQvn#95OgBjBX}Hu_kbIM5;oIlJm7GKpwy%e%v_kS z6!7{|q1?NIv%xjESiy_MO0`&q^LSTTzJ2WB;569z^ipLzzkv%S_=71Wsq3(>- z$pFOMxbI9Nf>mxR=uuaEVB2*!>iN};`wsgZD$7wfuBz-ApsKEs_flh>g?Ew`JhE1sjFlRlsSZo+-vz&cnLUp2i_xA ze%&TJdCPbRYxJGG)@(X?xO|4g@?+k6`phJ zd$u<`k*r;jB^#1t>to&Y;zqqx;wE}o!;SkH!i{y4ftw0Oo5v$PJo*o;4Z_~aWRPtg zB?fH~%R3i#w($CjZrY@wQHOc;BFV660G=*707@hetgBF+-$Jv8j?kcR;RS1+ox9Ru z{TJ7G_eY0!&+b%t-=4nQ34^r#PQmMM|AhC?Gq@80{`1#vc>UrX-glyCGS`WL1gIN} zf=9;D^z}}HG2^HpED0Vhp^N!Wl5r(8_e0?YBRNPoK0r;BhI$4n$A<*AF?_7}$YC?O z#ed7+6jY%VYK2wgbDD!|gDc%IwjBIE9eH( z0E+*gVG_Y^36B?SdtVy@w8C$$`$d?KRu~>m6o39Cx;Nm9?hZ{M5lzE|xPrdZWo`j# z&m$B5@np~>|CQvM;S4ebIV7ja6ig!*Su@R=ORQm+l&~O3*d`>)8kse(vE~YEa;)j$ zsVv9bsNN!uO&Dqo9ioOSt*)@=yIQ$Za>bgdQ#Z~tm2DX?*={sU%JC-;6U=t|zi8ry zK@B$ye_xzo*!1$HzJjrb>$De{)NXPm--W3h**ie&p-u`(>HUeP6Z@H8Wj>v5$CLZX zr^#Rc=rgf>X7Ufy?U`H8lpnW8&wX*WeQxG2JKrQCQ_?pml1ReB!IAy?)A}#1Pr^ro zNE+`90sqPSzj*)S+y6+8e>(d2WagQeIT{7ge&$<;o$GJjn!g(P)79iVp@f`rW2y@O z`@A5&*@8>TZXORj$==^4!yybDUWSO$-6n)w!v5~Mv2Iu**O;6~U1l2(-(K+K_d5Q4 zFF(9ZegIN-8Ju}&I$=Q&UW7uzUEyC*BuxJkO?`_ZZzm;f diff --git a/apps/ingestion/cli/__pycache__/story_browser.cpython-314.pyc b/apps/ingestion/cli/__pycache__/story_browser.cpython-314.pyc deleted file mode 100644 index c7184757a79fad4d681dcf25ae87f3195c823953..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4594 zcma)9U2qfE6~3!oNvoCo!%x5=4!)QGR=^VeNg#j$W64CINMa?6pBT1lX|YvgNx8ei zv4#hCrtLUsXKP!9g>AHV#A2cbu_QY#LN;hugD%nXu|fvzE$i*O%03`Ym$ z9TDE(*;gXC=0u4M^}A&P`|yK4p?n3`}teS1!$BtHQQY*tdY@_T-J{Vli04YQ7_F=L9!nYN1~~)jJqy=7?#5@ zeIl35Y6V3#a3qOi@f41YM59cU-NQsl!*cv1OwX6obgWC!@Nf#Bi$`KuB}Oijhej30 zp=RqKm&F6@D3Y~~9?))T1w+ZJx}lUyS=G?8bj_0kxKNzx^5bsYwXch*%dg{Gx9|bX zDhK?>j^zr5MsBKk>8uU0TNQF5D`;p4wH|YiN^W8r*GG$rM3@ zpnNdi33|($#=~a4&^s6lVKxsg$IvEA00Fq7_oNJ1D55`FMELPgecA=g3`(PGj-$T@ zoIzVVy1-3vGABD^UKV60{9Lj-DC|Xk@iFxQrU2^|e%xBpr!&RUcwh)>T884lbg~;a zdAM)W$!^4S5zQ!*f+@s`1?_7Dz;OXMCsjkId+9V!21fvCdQex1S_ob&k{iK-HU)^u z+z6_rk{-+z#x6+HFZni%e2e+9@|5+ErT_As=d#eLjGY2kl033*2`rp*tiH2$0E1rZbs} z%a<$siN2#IhfP;9HJBPnLcOQQbSL8@@=!QgahyD2x;~7ALg5(j?^oOyry{9nxZ=S0 zSnTl$kQIyh1=Fp4s%6TCW_qdANI0n^FGNl8x@Kf1)O_A_mIzpp-$SSh8}=bk*KCS4 z=$cOEWlfjmV@+S9_cVQt4p67(*uQ_j<%CHyJyjE1g*gK7iexXeG4uHoFf-_Zx8?SU z&rW>lT^L_1FO4sktNX&M-r;p`e8n4I^Jrq2%XJ)77^x4Vzp7~1)nfch_p2f)0 z*y73MEz3vmoLTO^GgX!1YeM3OQ$s7_@mX*n9gQ5O{i}mGz}nLN_;^49HCgSAj>qzkjBVC+R+Jw!p+I` zJ)xw!@5l!I-r?EjuE1KYJ#XR#=1{z{J>O>nUI$@8?zPb6sAEFrM(NeFl6KT^jUo?d zS+dl+71pn`;bKWEK&HVW3brCjm+n7S;W7TWB3`xP)j-8l3k3s!AQr6#Dgqvxrc^Zs zVtyxi69k&uhN2@$$OaTSoQTUQh9dG7^pJMi&F8g34MS81SQ5VMkB0kes!wYGzc<4fgg_rR+6J`ADAYev_+5Uby z7zQEb1jWo~sAC((vO=98+v2Cd{Cp&; zS>peTKi5!W6ew{G*3P9lu<94kAya=LJf@o=%#sh*3>2&vkE=H|TrOetI^;?CJuCkJ zJGJtU0Qh^$TD@ln(_v*K(ctSLD`2MTcQBQjPL>eq)Jg2emThgbY>O@ei6`VjkUkR{ zMwvbj0|Q3oYrP%)Ec)eJtG<2fzQBqvuGJG&^^Mb2Y4E;qrmo;x zK&R_51yk7kl=BR7pakB!|JT3uqaab=#>75HBG>tZlG!Z#SQ~-9%|atC+sHp*Ycptc zvm?>yH|^FvkaNh~5&IA&nxVWRHo&@g`cOXD{^sU?Hb z&5VW{rCMPnLBqyNWe*yqL6?;T4PP%6n*AKHPkEeExU!?nD|P9PQL02%F78QeukrP7 znoI1c*J@n<=4nT%$7BWOziOntb_W`z9+Z`Oynq*{oBq}1$?m-fgJ(Nnj~$2k?I;It z;kQAz^pB1>&cBUvoL{VT)}N>>d%?9=X8o~BpOsln&yZY+X1o=*{r>bjOu?8gX{I}? zU02I_gZ8leIt5cCwQLkK#YuRZ#{A8us6tAp5?wQ!2g!JO65b67S|z4W&BBw-ZewxY zl-NkxsGDv(qc`0&!H1OIbY#a&K9r2H#HgNxF^|p==;uurlR!*?%3v7Qsm38XSm+fd z)m(vup@{+EkHE~JhfQy+H@)*+(>v9kk+r7u%&_g{Sp)$LAeiZvAS<7dsY1>z{sW$IJYQ#BYiEc ziG2^n&V~MOc745TP3(UtzBM0O6ZbrKBdHgr!2frio*#9Z<9zEHnR`*#W?PJWc&+tK0+;zQ1c_?*$|`_VaB`B z=GrM#(YB3No>rgv=#%GJGbCTnT4{87CV{)z*ntG!Y-Npq^Ls)3?eG&G3A@;;{{=>B B4(5CH>>P{wCAAftgHh(Vb_lhJP_LlF~@{~08C%RxW1IJKx) zzc{fdF;71{zbIQjFSWe5Br!Q#Ke3>oSU)o_J+-(bGe1v1Ij6L^B((@ApdTNfnU`4- lAFo$Xd5gm)H$SB`C)KWq6=)#H%3=`X6Eh#I) diff --git a/apps/ingestion/clustering/__pycache__/apply.cpython-314.pyc b/apps/ingestion/clustering/__pycache__/apply.cpython-314.pyc deleted file mode 100644 index b4044d8f200fe96643159cd6997a88bfae266aeb..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 9275 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 UPg1Kibou-W1NOqIS677p1M`-z4gdfE diff --git a/apps/ingestion/clustering/__pycache__/decide.cpython-314.pyc b/apps/ingestion/clustering/__pycache__/decide.cpython-314.pyc deleted file mode 100644 index 9b17bc2de5942ba791b2c21a8bc2388c7c3aedfb..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5361 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 diff --git a/apps/ingestion/fetch/__pycache__/guard.cpython-314.pyc b/apps/ingestion/fetch/__pycache__/guard.cpython-314.pyc deleted file mode 100644 index 4a5817c6753734bde16919637f5933118a659f31..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 8960 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 NPe|IRrJ02P{{=n~s7U|- diff --git a/apps/ingestion/fetch/__pycache__/rss.cpython-314.pyc b/apps/ingestion/fetch/__pycache__/rss.cpython-314.pyc deleted file mode 100644 index d757a6d366c78382dd8f6331a679fd3009d0c4c6..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 7266 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 Lo_NteWJLZS+9{;5 diff --git a/apps/ingestion/jobs/__pycache__/__init__.cpython-314.pyc b/apps/ingestion/jobs/__pycache__/__init__.cpython-314.pyc deleted file mode 100644 index 585570060e04a51dcc0960af0ae7c73b1614f805..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 157 zcmdPq>P{wCAAftgHh(Vb_lhJP_LlF~@{~08C%Su1AIJKx) zzc{fdF;71{zbIQjFSWe5Br!Q#Ke3>oSU)o_J+-(bGe1v1D?h1NKR!M)FS8^*Uaz3? g7Kcr4eoARhs$CH)&?t~)#UREfW=2NFB4!{9080QRQUCw| diff --git a/apps/ingestion/jobs/__pycache__/baseline.cpython-314.pyc b/apps/ingestion/jobs/__pycache__/baseline.cpython-314.pyc deleted file mode 100644 index 34f6fd31bd986ac67790b5fb636b1c74535a9657..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2644 zcmd5;J!~9B6rR0b`|Mmoh=`)tcod`TEwS$$;)p0emJa)Z4;|-ZeZC+CvRd!X*_-U` zu4eXZ94IVA0TmP!K~T^UBnnCj8tCYVA{4V#L_|U7D1{{|-rFBv$d3pebK1E#Z|42X zd*95PW5qoM0V(;^_YHFM)x!$)RG^bnk23@u-#}uCH(`8*S7%*D8pf1cU&9>-IUr(1$C}rAa zd|CIcF70r)OZ`T9@Lbof=$^-Pt~&-3RCu=KQ{6Gcl_#gB zCi}D0==ts%<5cI2dai958xzz4M>w;(p3S<<@tH|i8E0ih3WjtCCczEY5l#JzEFpz@ zbFrqjXua85pv#snv}6l{mW0O)pZB^Gw9EWe*PNhz%VL6hn$0>s{u^k9BgL(iRbMGw zs<-BGF|97rQi0ONTB8QRn5!?g>P;Z@vhZELp;@T2EZiP97!F`*+-9qbHF{~j)}*`~ z`o+O*X9XOkB^m9?6LMsBG8`$ig^T5e+}9DRdV-E~G!~U(p;?*M8emUWfoRU5*TOOx zXgaShya~beEyIRX=)ien0bD@uEzAvFfI-A8pYM5~}fz!r7CCtya_4c}U0a?D?~ zOp`g9?$i1rZ7ww$yIP|-L^~CfqjKJlPGlpARj3SRdmiJ0naupBLfE!i1D_r$TeOzT&DvY2i(!KGgP+S^(dUUAsuD`YWfO8OEc#HXX_<}PEre#zoy$%J~2g2 zeM88~Lp`mh*NjluZ0z~j;<=N}}WHS`!p%eeN`FqYzDWw<@Z zFTJe9X!Se=(*rj}4DW|dZ31+>Wp=S1oo=vZth$0ZkJ!VLu&bHknFL@ZrX$g48huql zxc6@_gwIm54nVGkIFCK<>qJMMMQ7-+Qi$advJ+2tqCfR`Xm;-yeQl*w3LSwX(2$kW zrvb$)=Bw>`^L4swbcRk9)LLsvZ6f=>U7`g;f&zmdwhVk} z7(SptY?3y)k~;MvAtvxf$VF*YQYAH|rqxUZi&OSbENwvsj_Had}tw1=>9loUSYhxLqm&Yf={bCv8k zq2w5mW5p|wp%G%iGh>vDg}+AWOnQ`9l9Fo%xdQF~Z4)UwFYOG0C@B9+41vMVU#gB~e;ggEP7W%$Ag^igiqf>8plO{RAR3_- zLO&mao*w|A&}WX@;ix0p%)r^MYxZn5!w&-w^Dkb64opkZ{gfoVBmF^++#|*9%*ejX M=J3u8A~ux&8x51rYXATM diff --git a/apps/ingestion/jobs/__pycache__/maintenance.cpython-314.pyc b/apps/ingestion/jobs/__pycache__/maintenance.cpython-314.pyc deleted file mode 100644 index 3056755d210e144e2ab30b2344629214c8f2e41b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1703 zcmbtUO>7%Q6rS0&llZ4;0YOzIQAVw(vud!32!bjF$x4i3l)CNKX)maw$?iDbdOd5+ z%qB4>AGmPh(hHopAjFX)5+`ol0D)TV4XPfvrOF`^C*G{>l2oBVVs>}-&CmPZd*6FA zSEkS85wvf=|MC8Z8H662!MIsN0Uwk>7$6sEbPu_>j@K=1%^TUO+uC+*opCegk()h_ z!gD(7T9=SB5q||rYb*%+q{{-KxnRQMtIu?||hr9vM;MxugN$DN%uCGeuh6@NgQHT4d>|8l! zwa1j&jigV(1lLpp01#u^f3m)0te1{Ma!5r;fu^7pcxkv}*#8L`#d<5+d*+$~E@MUN z4YE*DTnLF-0i*=hqj)hKH74w6 zqy&acX&Oe`fiNX?bmbI6Mm4#T_b{AK^O*)GAK5TGARr9T9dyrn_Y;JCn43kna2vbW zwOrfHRLt4V{%LN8B;!RweEf1x^}VRKU0#I|_Y4WigycGwBFQu&!M{Opz$RLe>@uy4 z!Hdazl}!_B1-ALSS%NBXU#QBF3#8oZCq+s}=ao|`3jp~l zd};=|0Xjq$zWmg5KTVT;^Z&A$4i8`%rBNHfv`P5VsJ{TgM16D2hglu9<v^kJ+(e&!GT2 zxZ&7IK?7Hc4GEs)d)$jPPtIPi)*H=QquN-l(c8B+94xZ{neu#DnyWprp~mG)Af+TN zf-pdja%UdqF8rLk@Gv*?TW;pjRPnRor^N^K*QwVJZIqvdX?TCh&HSZupz!%?SF_(0 zs`;EVkxWwRM;@g~p3+tfX*M}X>tS+K3Gy7Mq!#6rk&32EU75Ppjr=&|?@Qy0AxF(Y gw{K&NpIR8-#81%0$7p&#Q+OpaID0q?*-O>`2BZzlwEzGB diff --git a/apps/ingestion/jobs/__pycache__/ratings_sync.cpython-314.pyc b/apps/ingestion/jobs/__pycache__/ratings_sync.cpython-314.pyc deleted file mode 100644 index f1c90300ac00381948d4240400998142098146e9..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4826 zcmbtYT~Hg>6}~I2R?=#P#Gm-JS(}(h1W=q9<6y@L#!%!RkQbr2jLc?{Rz{A{D!VJF znRLQKA8dthgj;BBFn`}bSZ2aJ99y)o$<7AjgUV84XRstua z?Q}KV|9kE^_ug~9@A5*Gn?~^c^pAhPVq*~c0$0q>Y7~g4_o1+eIHaN}#1SE4#G+c- zaM=>Fs#ZNGRZ`Dws!h)+mAXWRt4vXq`~bS-I6@fpaBZdCEFCEo!gXd#C7-f(HCJk! zwaZnhsVt%woL*0SvX+o{yEbS=Z%zX~CI6+NiE-T&2)l8hd zB2BaYFgJF3Vlcwq`%Q1p;Xn_|i9$kQWn)}0G#WgSkY zK}CQoAACcS-wI~Lc|{fCZw0|)lpwgUsNgk%;KE9pPVhv|lC$#2Y-xji4eyj!n#DnAjl%x!3~@o?2;N)x!Zst{nVJ%_)~Gevh|efy zH~TsASy}xS2648RtY+|?A)*kvcdnKt_s|T^I!gYRy4a$&=SxZ?09sm66aZo8D(4B# znx&#d*lFro$vH<4GA+*9Bj3AEF&(h*9L^pgIp-ddoTi-|y~hZ;Ji-Ng#q89PbMG<7 zU7k|~bER&NpvSyhKjcljPbBMry@lVAlE=28Sedk3EEAb_#G)6FY4v6B*;bSSinI^4 z>A!O=$F2KNiU>3<93LM#2XGCsmmTKD&X<5nVc!@T0t7cG2Ky?+exn#r*x@mbod};E zhzx0z(Wp^I3>{tYWh9e(%%{{DbAGNK4wU`p;s-1Mi6qECK~x|svZ5}`8KTMnY496% zd?-=@$OJ$r=#Y`-{ehBVWoDr3R<4111EZ&(nHd^qM<>pmV|N0L<}Ak)Er4sjn4r8)99#>&WS^^EXkVnvZ!hdHxvzxhsH+v z@eAiPYg)``Bt~=1mdMR!6&VxOfK#R*lQG&F>y{x=nwRm(X;#ocK7{*yxJm))0eCAo zVs>6S_ZA zh{g(+#EodOaQ5<3tHnqEi>|rmzuviJL(bYQ3NgNiu6>VcS_^HxpCs>G+&DV?pf^+q zO%?X>>oso{m^X`c`}1%A^8AwXaaGOx>1*jN3*nw19=jX!fdcc~Q#*Ye5RAGhU)R}cR>bSF_{TJ8@2 z?aZfV?!UWvntynjza3eb{AhBGT6=fH|JrB!UN49#Q0Y#`pF020d3SQNKm4#ie4AXM zKcZKUu61s-_kPxRv=E&HIr-?yO zio7+hZ5AQipW{@iu~(!aF!Li*R5fi}qQldh3E=l@0N96F_Q%9w5#*VK+n*gf>it1%|Y zk|jx9V6uWblTJ&9z`f? ziR+S`Yo-Ml$T%|Vc+GM4L!e!HDMh4E3DoRg;&}^Bcq4b}kK+YfPEB?n13vG7*P)aE znm!7c1(8ZjXq1SL+?MlN0-P)?&{^|RvR|mfem+H?ZP97s#Cw1S{+!g?^qh%wxX)e zEs}H|d<3o@$sb+q|BTTCnDFzn?+rh3q3XUZFLKvIsG3=$3a(&*3~qa163F}ZD;jy5 zze0rbAOyMh&tE%#yFD-bs$-Km@Q^w1?UoffzERpi$=^2&^w~dc@SUP9pZ2*<*^Cn&*=_cwpft5WdfHHzb<9Ta+5X$hQ8%u0z| zT0AMAhauQ4l{X;)TL|Kb#X=nUnnFbT7s&NFs`>&|eU4m@J-+wfdGDS4$hzmyrl str: if len(normalized) < MIN_CHARS: return source_language m = model if model is not None else _get_model() - labels, probs = m.predict(normalized, k=1) - detected = labels[0].replace("__label__", "") - confidence = probs[0] + # m.predict() wraps its result in np.array(..., copy=False), which raises under + # NumPy 2.x (fasttext-wheel 0.9.2 predates that change) — call the underlying + # binding directly instead, same contract (list of (prob, label) tuples). + predictions = m.f.predict(normalized + "\n", 1, 0.0, "strict") + if not predictions: + return source_language + confidence, label = predictions[0] + detected = label.replace("__label__", "") return detected if confidence >= OVERRIDE_THRESHOLD else source_language diff --git a/apps/ingestion/requirements.txt b/apps/ingestion/requirements.txt index f6c40b2..6419113 100644 --- a/apps/ingestion/requirements.txt +++ b/apps/ingestion/requirements.txt @@ -1,7 +1,7 @@ psycopg[binary,pool]==3.2.10 httpx==0.27.2 feedparser==6.0.11 -sentence-transformers==3.2.1 +sentence-transformers==3.3.1 torch==2.9.0 fasttext-wheel==0.9.2 APScheduler==3.10.4 diff --git a/apps/ingestion/seed_sources.py b/apps/ingestion/seed_sources.py index 06d7947..8b23331 100644 --- a/apps/ingestion/seed_sources.py +++ b/apps/ingestion/seed_sources.py @@ -22,6 +22,24 @@ dict(name="Al Jazeera English", domain="aljazeera.com", country_code="QA", coverage_region="global", language="en", feed_timezone="UTC", adapter="generic", rss_url="https://www.aljazeera.com/xml/rss/all.xml"), + dict(name="Hindustan Times", domain="hindustantimes.com", country_code="IN", + coverage_region="domestic", language="en", feed_timezone="Asia/Kolkata", adapter="generic", + rss_url="https://www.hindustantimes.com/feeds/rss/india-news/rssfeed.xml"), + dict(name="NDTV", domain="ndtv.com", country_code="IN", + coverage_region="domestic", language="en", feed_timezone="Asia/Kolkata", adapter="generic", + rss_url="https://feeds.feedburner.com/ndtvnews-top-stories"), + dict(name="LiveMint", domain="livemint.com", country_code="IN", + coverage_region="domestic", language="en", feed_timezone="Asia/Kolkata", adapter="generic", + rss_url="https://www.livemint.com/rss/news"), + dict(name="Business Standard", domain="business-standard.com", country_code="IN", + coverage_region="domestic", language="en", feed_timezone="Asia/Kolkata", adapter="generic", + rss_url="https://www.business-standard.com/rss/latest.rss"), + dict(name="The Economic Times", domain="economictimes.indiatimes.com", country_code="IN", + coverage_region="domestic", language="en", feed_timezone="Asia/Kolkata", adapter="generic", + rss_url="https://economictimes.indiatimes.com/rssfeedstopstories.cms"), + dict(name="News18", domain="news18.com", country_code="IN", + coverage_region="domestic", language="en", feed_timezone="Asia/Kolkata", adapter="generic", + rss_url="https://www.news18.com/commonfeeds/v1/eng/rss/india.xml"), ] INSERT_SQL = """ diff --git a/apps/ingestion/tests/__pycache__/__init__.cpython-314.pyc b/apps/ingestion/tests/__pycache__/__init__.cpython-314.pyc deleted file mode 100644 index 9f2e7aacd1d1a8eb2b0d026c27a0688b85a5842b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 158 zcmdPq#b^^-jAIh#b%35r?kqjb1_wX{&YXGFS-=eY!%PKmNMVw&29Eatd zIbbof-wQaCtxpf~W;92O^vG~_8HI2S7IOz=8B+uPC9qTKnLLR2Qm^D!F7|x%|J}_4%q@CA8b5trl%L zUf@gWOQ-FMHmNOXm+;<4Q@pp%i46oFfVr>nwCQbw%K-BK4~a z3klw4*u=;9Jt%9D`jrN$)ye54q=?Ph)NOT`Po2Tk4u&MOg$y{uCG=Sz6sFo?G1anM zFQ;QO6g@8pS7Q^&`_z-M-fG51WvL#U^fvV(NpVjpj7W4 ze7Yf|>#bXE7z&H|tGIO*_?FtfurPN;X%k^DK7x1T`cdfguftI|FJfKwlZsjVY{Ksh z)&q#=J6-S~dUP5u=H zEbw}Gu8@9LD9jzpl?!pU8L^IJzKDw#!k)8h9d*K9Ldq35jbb~&=>}(##<^W6Hg~W* zCaZAVI8S4fIMcVe;vJhS?6!IT;l9(H)us2dY+G>sTL?K{A?MTrhNst o5BCmzfuZk^+sc5ReK3A^{Auc=Huh7`YWZyg^zmOx<0F{!59IG4YybcN diff --git a/apps/ingestion/tests/__pycache__/test_baseline.cpython-314-pytest-8.3.3.pyc b/apps/ingestion/tests/__pycache__/test_baseline.cpython-314-pytest-8.3.3.pyc deleted file mode 100644 index 93da66a546a46a2bd95ba4534bf54de9448bf009..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4263 zcmeHKO>7&-6`m!RKTAroC@N9pO4eGoDs97x`twiZD6uNhks6s|*Aiu*inq(i&8@u#y|h5k^yaoM9Bfl0K#CT+~0_*LF+}b|E`1^H8?P+$ki5{>Fy0 z66Zz>D#eCw;wEb%Fd9UN-+K&p?V#1#VmaT=ngpSfXqET7GA9SlRdp_K3g-lzpn@i< z!)265IkxtJM-EPKo~Z9>8hl+t4zbS!*vFo}BUtCJahPSRQExqja+O>1dtQAK?DA)q zxq}wSp>p6Vmk)ds-5ejRPF(*vLOp1;#a}1a=C|tgNhHVf!EsJ*S?L%LOjNxnhvmp> zyRVh&@LTozWX+>5fpER|z)pw60NBs@_WKqVyAI5IzP^LsYMghOlOuAp;Y5e)2mF1~ zffPi!|CD)IkYjm1%{56wtKGgv!^+2fS-l2JLjKBry#Y6Ifg@vb=oAvsi^xCOIz(U( z(&3%yh4^5f47bpEA=ry_?mz1-{KeCfTDRpH%P5M}R*9Vww=uCqqT0H-PA5fs1B-Lh z`T6W?qGma!J@hq6d_^1)HA8hM7E4$)sc6CkYg5#zsGAfM8yAO{-sjwy>e!ZMl}ft( zi{WZEmDYw=}D~?nX4rgaiO9&0fj65qt}4j*SU> zjolz|OgDD1yk%o*D=Kjlil!QdLU5VDDatCrZVL=61xGi)J=6^w7HClS1%+-{j!~?r zD(i+-Pz}XYOV||@s;*(Sg4;G-FUF&76OEm-l!c*KdEATXW$+VCQ=+8p?{k|Xe zy_5WD=ObbG55n-zPJbM2x!-cH<-v2K52IsEo&6Asr5}e;EaA<0B=kNMdf)B*B+>m= z&rf>ZF8)4o>TY;Hh+-%H%6C8dGQPj%yZ7U;D;W1c7=E%3o1cJfpHN5==)F^!WayV; zU75kqhsk*6wD93zJabNHf(({ESxVl^LK-^~WB}Ct50FtyGf#z#22hdP+R>$p2yH@4 zla8l=5WkXuBwPir9J{*%j}K$lP^uw8m4i=NFSIgNU#}0n{tXroClI7}_S4D?Q-nD>W2OFe*j>ja$Ii7M-Kpuu=<>DU&G3WLzm#M?AHJ=;R{@ufy|qE zUT$sn(lzvdFEQUXdPxpH0~BvR3lsxnU#^02Vu?UfL4@UT5`kj-gZb>0jJzbyXO|X5 z4;`sE#Ly)r7Oj%1n<)>P$(Ev7MUVoTtUKyDwAL^#Dz;w2+mN>r>DMjehH9&WlDL=^XBM-U7UpM`#364VNt|62mvghzfa6y(OCqyD zoDwrvXBL)cGqb~$zMNTFma~j6=tZ>+xb7Ft_klwks)D^eUo8!xcp_MO4W$?McI3()!Y?S6+C{geHp{dZD+ zd#s^7-hbx}Uth|)Vb64zFI$$u$jc}zCYor?S&!D7b>jfknBljEsT7m~+k!ESZ>goS zfnDCfYc>;UHBnf@CLEs$Nlh3ca5h`wo&=_*e1D*i3tVQTk;{ye zudrzqs^bxwGmV{dlqGwQ(6Ey9F>Q~jFkS*2lq$@Tisx0uF^iZeXGTVp2G0=&XUu4N z4gzuqeR33r^2eUyy@k7%KJIw_t<;ZGyMaLB{AZIFAG|cb7vYm_`%!eP>)ytXuD_l5 zr@$wB?#}GSP;27;!o7vvKroTzcEjjf+!FWE?SBcO*0#U5qF&7HM;Pfj@oc1L;1Pf1 z@9+45PmeweHhRbp><=_>Be0QiqX�vAce6T6*o((67#S%_T#>elxcqgiK)tA|grf)p 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 S`}j|2AHDAOCLi5EYWja~{X>xe diff --git a/apps/ingestion/tests/__pycache__/test_config.cpython-314-pytest-8.3.3.pyc b/apps/ingestion/tests/__pycache__/test_config.cpython-314-pytest-8.3.3.pyc deleted file mode 100644 index 56cfcc4253a783bfe338d1286b92a7b959d973da..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6302 zcmeHL-*4N-9Y0bOB~i8<$64HNX)PmdV|lS8J5FLdb=Dy%kPcX8To+})oDj5hmN-)+ zeMhyHi(QN+ML{h3JyQ%=0%OvxgocEWL$1N!;Z-iIzNt z?kHYzDtMxLDwZdam%<6KX{>ZOXfZ6gsDd!M{MFt=SHjZvbVXjegV6SsOW}ih8=(tmU5VPQsZp+XpGG)aNnDZe z$o;cdRA{mVw-5U~CZ%D3mL!t}drPP3z(x+bH{u}U8X245Y@$_pFpIO{bScQM_s6SS< zTFfIz`w0)3gj~dTrfnP{JCH zRjX^AU%RW?U%uf_u$JpO)FU5$5Rz^~d$9VOcsQF0DFE*Pz7D=*-w8yvEOc zQ>+@c^CqPZ^(XXAhc*pQCp#{z$nfCFBh^n-H|u^DM%9B|mg9vh@xnq-2u8UYFI;CF z0rKMVZ(ZOMD|t*l>GAx9!h~?H#>+ zy?i;MXnEX7pv4nIjzgJ(xZ8C+$o}yR@%edZHEfZXGIx&hTkpDI6-r&hTkp zDIO}N&hY6#DLsef_j46TOu;0-fc^c~nBjw93!BTIJ9GlvX)gNrP6IxPK|8RWzJkKNHQfb~eiO?mgwS43+ii zftk+rT1L5Pw4RId(Ym9R*1k5-|44oG<^JE6a2Aim6wQ(SdVU&4SK-kU$4y21C@Fnv zD}!@*tdbefVA>EGOp8X2?jdV(9VUYg`f9Y)-8U++5#{lkRdry33RcM#|q}0a*0r zYC+m9a~1{a)ZPm-`G8H!1vbNL7JyZ?_wzS{+UrotQvLz^lY(JLa|4LPwB42$QV<>I zbMP;YPH@A&B>EL@_{ZXpaHia7nU-h5!d1%w3TFNbIz1=&Sh{UA3CuRh z`@X=TN&2rA1?hK#ymFYv}VrTfYuUtG-<~sl8bfC=9DcJ1MN3|cp(d=Y` z#VGnSh{KxHFR&ke^e-PnG(oZ1{|}1AnWB8^Dfkro4r~HWM|LM{$0j>~jl}lmjGEyB zKKkPvK~0Y|neM_Fg|_UYkv3a1Jfd%!#Hukr5j+0^RVWa&<}v7*aMpWar8|%n=pL?WIw>aUMDu8W|h7UgK%(VKLry)&nx@d zD<8kQ@O8XLJQ8hh)9kWyDDMXLchlLaZ004eib@X|=Xm+&YAOHTa+E{yvLXCYW} z#?QX}oqPD!n{Vs4-@>~i;s3hWHk#J%FB=YzOjxGD2wTa&_r6AUJZk9ht8Vci2E-_g zCOrB618s{CmmlXF8xS2?$$PX#v>1Rvn=XPq&^*(#2t-4OVYgh-o$OQzbv-=*9jIch zX49bU2SRW5q2)?`pvsH-kIL%5`V(a95Fb} z_$fv_P_s0^Rqb7f3K8AoyT8Gk9;Yb_X+P~XU9N;1ezxh@5Ym8rwegQtBjf+N*Io-$ zUx33i$Kr64P>8qa4k&eM)(E`~eYqTEe*m+GK2^p#%H(5Z@>lAIqrctxL|OVYIr>5F zAo()j@MC56sVrr-q|XzHr1~_C)Z7Q72g#QpsPb4Td^~&W6XmvuxNr#n@Ow|R;(t8m za-b9if)}!WI@0BI4z}jMs0X34Vi4GZfK!nY@n-x?x5`h2b-_F0M*|U3qFf^jwh|6| zgv2C)uhXwLom$HxH|e*a5$6V$2lF%`Nzx%gKS4hpeWof>`B_epiqCQhY31{gj8qaM F{2lX=BT@hW diff --git a/apps/ingestion/tests/__pycache__/test_db.cpython-314-pytest-8.3.3.pyc b/apps/ingestion/tests/__pycache__/test_db.cpython-314-pytest-8.3.3.pyc deleted file mode 100644 index 5f3256d0f25730c58d17170422ce42d88d44928f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4216 zcmcJSO>7)R7J#dNr+a=J3?V-@-m&8iq%rX%@tFJ=v*gDi35hl8B$ETqw%Rjodq}&x zUDZQy5G$`htjtCl4jghtLfXw9_R5I^pdfA=?^?3efPChL2vVeo!@jDXni&sHi3O9RME73C4ZOkW!u5%Y4aHx*Nz>f$zqCwQru51@R#avbv6fwq~R zY+CZtK+78Vy11w1-nRZbd9P-^RsTzZshWwF6>izDDDwGCt)s2PZFABKjpu++pqRRu zEGju+lUAMD%{^=xc@Nh$^8r*;`N%w<6HIAL2*fb~pEf1fUzsR<=H!)=r%qm;u&$aH z&l*CMz;=aHyxGZHF9tpdXR$*T+!MK+=iDTrUAl3C7hZAwlI_lc&568&#GY~J2vOHb zwv>wROOt~?|V0##lQ|rb2vl&O3^vvI2>-R}#I zG3sZ5DHG2sJ2Q6G4T*Jg&hab=s#W!f6XGn)L|;tuE{G+xF7H^Acdf{~KK#o+tM}zy ztMceK^3HWRvnKCck#~M%uF88h1eBP+nl@!vk8j%VxkSc+SFdY zhL(|gxUQMQ3N=15&jG#~z?TA81^_#zM*s437emUTU)BdvKN>uWvQG7_mBtCqI(AdQA1&RMa4Gu7na@XP12&>owm;#LfzgBZ4 z_%N8$(2-9;ETL~xncCO~V}BoCIzFTY;9e*5~J(t3JF z?X~6C?j%>!gBxjM?Q#BA(#Astb@e`!P_lPL@7c)o)UH2bHSq1e-t&kIf$#s`dlo65 zCr$|y2lX%YQ%^#6uU7-zy@RQV@yxwrwEPuHo$c03VBSdd1f)Ct!O!f{+KJaK?erXK zOZ<&uG^e|_Epd+h3e7vQS=f@z`&!s60h>=3Wya>KjrViAY*rYX^N*d)0*@2-aGPvy z^xGzz8!X=jo0}Z(%;pBi+t{o!HmhxHPW%owCwOFdUS~60j47Y-m39`xt$`w;E%ALu z+1-)V`(g&;eK3fzBgS60>)2%nw~~ThY_LJa^Hk7xZo9CHl#=Tc$I$RW+G{iH@G#Zo zsGuvshd>beVaJ&kBkhk2X}@sGsu0WbV?;Y;JPQ3Ne96B+Fr2NWvj0tGKN0_-eysj4 z^<2!g-sRp}-|fCTgX>zVmRe4Icwtq`ZfN`09_MdG+Yca1?*2hVivG6?f4%Vj#fJ%~ z*ZLk2dQ5x<>>5(O_!F>eNVzwZni$iKRHX5b0d}55d#(8MVK_bz;4m7-&P`l_Ge~c! z;8Q8qu?G~1K4DLl-JTioS)A@7+z->n;niN6_sdn+c^SV1I(niKde{Cz5(Hrbq4&_c Z`Zq}bQI>^C;m5QhJpWUdB6PC>{tw=L+T;KL diff --git a/apps/ingestion/tests/__pycache__/test_embed_input.cpython-314-pytest-8.3.3.pyc b/apps/ingestion/tests/__pycache__/test_embed_input.cpython-314-pytest-8.3.3.pyc deleted file mode 100644 index bc221b049dc09dcfc49bad2b53e2427545f784c6..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 10412 zcmeHNO>or66_zxbAJ305KMBijvdG3Z7ML0Tfenk71#AL@b-aQdk}4M}c}BJcV@Yml z*7j_X@?kGIC0jX!!yY)|TMjvJNTrfgDyf`8#ipE6rQ-A6HYPt+R1SHsHPXoAvB#zc z!lvk&dFpO;w_4rm_rCXapKMOW83^yk|9JOr5r)ZO!V6qE^6V}|mY6$?#9m`;mR`B5 zp(1ybxysw&qj-nA7P%U=qeq!EBPGAb+A%3~n34FyOrgbYlEQ}>J1#{)CZs6Hq!a_0 zlA1s^OL36BwAx$PyzlfB$-&?c+87cuC0ue z$cFk^DS7)qKj%G13Q7C~T}D`P9Oteov@+hPo`pNp->!O6b5(IjwnE>%vG;nIKU`I> zW#@VyvJx+a18-EjJnKD2iqtL3d1W)9Zhxd;M0qJH#j<>c-IN=iIOz2-(DETKuX2UA z4SOwnS6IDG@3AKP9!vUQ9=};B?Zsdo<2aAk_dbg$I*ZM3W^vPez2sSxnm~)}bHcYY zyP%emqYF8en6@yZ618-YW~PN;rV}z3dr$*g#e$8AJ`|^)H!BwCTU+SpG8YF$3o{4D zt}L_9x#8huw$zRJeWDfrv>XwJY0)0hN~3;{-xr46oYSJU%sQL~vedUzE$pyt=!He7 zt68))teYgL@~Bp$YS4*)U@oGHmlfjdmvc&?AQROjIb+sTi0UNawu~mt*3h&R3@Bka zZ^_n-Q3NxGMdj&&F{u<}U71y#CfQP^V0294G+(6e3^rtx5Q8`eW!D7B>VjENbOo)8 z6Sw`2E=LHi7$<>`bjPbVm>38q`rU-v;+bG#$ih`F(BLX|*Lb-PQgpD*?kvh^6u?N& zYxaz6szfvLGS&X17aBn2X#EDn67$#8yAS3cH9zK7&yTO9K3cl6&S%yd{afKP>wL#r zqkpS>$ICTK%nO^PI8|tgO$v|)4UD2||b8^qWx@(#uU z$uR`MED=z&qBsd+i`s0XW}%{=<@p&^_m!tQYubwhG2%X~T+x(2d_;cUw>tRUmDCN? zlwE6${;lv`>wNoKqkpS>`wp7&*EIlq1-?)Cklh2=Cis_gE#tn%fH5iuTxVy&5!eFb zeegc+Ye26Kf@`TJ0Gz1QstMw=6{w8B+K276HCPSEUuhpbp{lS0&*Q#q6x^|v+miuz zEb$u8O)@ZowdXS8IY6)FJVwABBNWK*C+NtP-o9ky7X`%C!pCMF*o*u*p||JUK%X+} zM9Vpd(0ftzy9%{RmzG*?A#P0zep9aUMZGJs&m+K!2%0Xkr5?oSy`m+!IUWvofj$88 zGSKh$fqq3vi!N4oVk4ttKe%!2=B)~9CvET?atsB2e54&k2Z~M|c@=y>vJ}0g#pB2b#77rkkx)_qRf0fu0_4%0I(V$I(w7reUU^`BS1oP2-2L@k z8@L^z3pK!I=8Qr?F)cMuK7b{Oh|s$0ugy;%-FV!#`o6T1x`|8k6|EtpN&!+a&wj=4 zb`9-4uyBc1^tWv2l)?1E8EWqB~+&9{#QO!(K^b@kyD1!F#N zX&5u?!%%OmcgYO<^#CScJohrYQd8Y_qh7n2@TM=J zL33{MFzobto`P2@hq*KoU!YJG=Qs1J?^a=>9H@p@(x3q0hqz(ZrDklK5$qz4FM zxaHzQh?Mc+fR_c4a@ICbsqTNTqY>&4tU*l>$~L8N9b!%jU!@JqHv!0;YmoL4gQ0h+a=qxEdsY z2ZM>T!LoxjQ0;MQEOE4C&64w~t&&+y2Y{>PV0SYwn?#+`7Ql3l!-J7KvHk-BU~TNk zgU*M?AN8$fF0aH!mM(6|Sm#f!z4G4*fAXb1_bGpJD~V$7SQc*Bi(!ZYSl0@+oE zz38wD4tvRk@l*p*T6Gk@c2^gD2V+-JTmu28Kb~`~!+X=Jt?sto?zC#qO}LJatBD<#$(k={!lL2X>&J#F~;DBM CyE6;` diff --git a/apps/ingestion/tests/__pycache__/test_embed_model.cpython-314-pytest-8.3.3.pyc b/apps/ingestion/tests/__pycache__/test_embed_model.cpython-314-pytest-8.3.3.pyc deleted file mode 100644 index 5820cd3a91738dbb0aabbc122277b3cf56cb5f35..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 9933 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 NT>SOEsCb0W^WR;s{RRL4 diff --git a/apps/ingestion/tests/__pycache__/test_fetch_guard.cpython-314-pytest-8.3.3.pyc b/apps/ingestion/tests/__pycache__/test_fetch_guard.cpython-314-pytest-8.3.3.pyc deleted file mode 100644 index 2f1a99686ad12e8d1ea390a857da5c2e9a30f8d5..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 15269 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 diff --git a/apps/ingestion/tests/__pycache__/test_language.cpython-314-pytest-8.3.3.pyc b/apps/ingestion/tests/__pycache__/test_language.cpython-314-pytest-8.3.3.pyc deleted file mode 100644 index df20c5c7373f040a985e7e3e78fd212eb3fa2f5b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5907 zcmeHLUu+yl8K2qPy?;LciIX%0f^trZ$u_|k`<%ofahy;)aiMYDC7eYuS*`EZ=f#`d z^~@frgOE z0B64sj%vi0Zpwb*IpUHMDN>CdB6eDnzz+DbmP8kA>MNS;CriXnX$m-z3NlxXRr9)A zG3_#i(eRH6S0e%ctzj@1X&r{VDNVdVNCj$-kXPvnJmA~(d$1N@X+eU)5)aX_K(UP_ z(+;v_*I9;XIb{>(nBYDOlx5pBd?+QCU3SaxgI--P+Xm+q3#O`b?0&2sTrPZvn~WE@ z!3?Lc;Ih?%W8UVzQC=+=p2rK8Q#HA7xlX}{oD17psS$5I$mzP_IIeH_rmhFiwWc@{ zuUFt{{7Yays*@*#Dia@~xmW%Gvqnvnw%|4?<3dCUui>BGItCYYvM^YaO2moo!ZJD0 z5)zHTqR_d9#L{q|Wx%M3K<1{s+^*p`;PCJ;>w`Q<>$>Gwuol5!-084RI6MH&>g4_5 zlN;&dKa=0g{(Sluudsbk+2JLwr0^0Wqz;~x$UIziryLoRMkw6F_FZX&7I9N)kkMgn zVMleH?T0pMf*pV?kbU!YKcK6f6G(Z?#N7<`wf%2a;Ue&G)!yHK{=lyDx9- zQ#S@)_|1*q=?#73)Z|A0rH%9@F(FkFOD2|TPNffVUDY%$kz?G#ea-6nEw@s$@fvn~ zt!CKGJN>%8Y%%WJmSZ|DIDQcNGTiInAjBEOg;mxK-)GiR4MGU|?~6rZ*|cG@DrLRs zF+TRP*d2x&AdHMR4ukpVr?9$DBnTK0HBXXMnR*QIGbLsq+LobNE8!a8hHw25hOLvu zSgdGt5$}kkNm?Qz6HPwXtoMMD)&@#+VwRA#V_$8yt^OVXxri=_G@p#F+BpzZPe}q! z$SuIGjyKzCik4hNk3^am)EJe*e29!UE6zh+{c_ina-FTQGz5`0-keqBVdYR)KMeJ* z6z_MP0$ZhGGupj9jh<7GPQ~WzCA74bDJ2TDqgGx-kcbo;c`&ML=irGPjU44bcG(M* z4QW|GL~o$*6_@#eddX3egMrh3(Omke7#2N2DTWfMmT`mPAS zfJb6K!o52;xvX-LY%$_0HC>K9CP@ zej2}bXnzS}lk_1$X5fGjcs<~1EpxV1I_+N)= z3qp~H&j{64zh{K16alFVseny_xmY1!v zWjemSp0{16n)fWTZ06nN{ESnzExwY+kjz6I=R@@cu|JY$sDKzQP&K>xYi~09Xo{V{ zsu$28FxV+HUqdqjW^Vf@pZKAlcmP&FcSg~SfoUWe9#^5e7w`&&0h>f~2@FR96*z4I zbwU6YS1H8lid9|F+lo$ie`qoW>TV}^r(qT#i?iZ*?5s#=@?uL6&9?+zykCc`8|r$}u0n9J)jeX` z$kPhwY7n4pPj{7tJNv`gA#2KB{n~O8#OfZ;D7DTdkdJ${ZAG+}-m`5YnsY(v5S5CS z(Xu6_>-tytn53J%m9 zUh_QApk}45n#6g8vqo__hGrbiSv2Rj`(pxhbQBb*?5&TE&IyR9^v-!ypl4N{{ZaEG zA1YB0r6;gvOy#G9G#yvDn*DJkRL_G@%`ki@E26=;VrS5tMRN|#1R8vQU|$E*NQt8- zt{;o>!kKtsB3?Kb>fBIfj-Ku)jqwzW9UfrK6YR9W_0CT#C~);%7x23<1ia!9z6PdF z9>_DBpVjXjdFClBG7SsdWD)Wd7NN)@-y~}vmqW6GLwXkC90D((pA!y2w%ZHqtyj`# z;F~sw?9DfA4%wS;{ym4_>*}Y?A;=w_972IZa)LkRLg^|{`z;7{_A;95Xy(wof(Abq z!N(uT;{O3VfZj&Z0Cr%n;$Rw>od_Ly8bWs=I6Ok&tZL)4xG8@|zYV!4@Tv9(>F7mU z@LX_M4`0nkLVm?p24>!7mTv}`R#lK{)(Msj($QDlkU3E)gx{9%#g|Q^L4m=LkQmOo z5o(O)H^PhISFcM9H3z()5XYO)~K$snDqp37E$N68+-iYywP97)r0w Lf21iL7ZdsyGm|t2 diff --git a/apps/ingestion/tests/__pycache__/test_maintenance.cpython-314-pytest-8.3.3.pyc b/apps/ingestion/tests/__pycache__/test_maintenance.cpython-314-pytest-8.3.3.pyc deleted file mode 100644 index 0bf7b76d52c45beab92728c8845252ba69efec6d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 7023 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 UasQ{yJl_A=?QVPqlanCwpBm>X82|tP diff --git a/apps/ingestion/tests/__pycache__/test_ratings_sync.cpython-314-pytest-8.3.3.pyc b/apps/ingestion/tests/__pycache__/test_ratings_sync.cpython-314-pytest-8.3.3.pyc deleted file mode 100644 index c7449835898f7f31a552045119a6f9d668f30d18..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 8018 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 diff --git a/apps/ingestion/tests/__pycache__/test_scheduler.cpython-314-pytest-8.3.3.pyc b/apps/ingestion/tests/__pycache__/test_scheduler.cpython-314-pytest-8.3.3.pyc deleted file mode 100644 index 013533fdffd849ab9acaf1ef7ce193ce07a68861..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 9423 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 diff --git a/apps/ingestion/tests/__pycache__/test_scheduler_jobs.cpython-314-pytest-8.3.3.pyc b/apps/ingestion/tests/__pycache__/test_scheduler_jobs.cpython-314-pytest-8.3.3.pyc deleted file mode 100644 index a24d6ea4f2642742f565d3b74124bd153ebae384..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1671 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 diff --git a/apps/ingestion/tests/__pycache__/test_story_browser.cpython-314-pytest-8.3.3.pyc b/apps/ingestion/tests/__pycache__/test_story_browser.cpython-314-pytest-8.3.3.pyc deleted file mode 100644 index 77f63b8c9a2770ca94f28a3b33986e9b04809f63..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6483 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 diff --git a/apps/ingestion/tests/__pycache__/test_sweeper.cpython-314-pytest-8.3.3.pyc b/apps/ingestion/tests/__pycache__/test_sweeper.cpython-314-pytest-8.3.3.pyc deleted file mode 100644 index f49a3070e75a8440164a7310e49362d0fc777f95..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 7806 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 Xu}|9?nFF8pM3_UL9f&Y7v)TU!o9f>i diff --git a/apps/ingestion/tests/conftest.py b/apps/ingestion/tests/conftest.py index 7de60f4..b0a622a 100644 --- a/apps/ingestion/tests/conftest.py +++ b/apps/ingestion/tests/conftest.py @@ -6,7 +6,7 @@ @pytest.fixture(autouse=True) def cleanup_before_test(): - """Clean up any eligible articles from previous test runs before each test.""" + """Clean up fixture leftovers from previous test runs before each test.""" with psycopg.connect(db.DSN, autocommit=True) as conn: # Delete all articles that match the sweep criteria AND are not referenced by stories conn.execute( @@ -14,3 +14,18 @@ def cleanup_before_test(): "AND embed_attempts < 5 AND skip_reason IS NULL AND hidden_at IS NULL " "AND id NOT IN (SELECT headline_article_id FROM stories WHERE headline_article_id IS NOT NULL)" ) + # Fixtures create sources/articles under *.example domains (RFC 2606); these never + # hit a real feed, so purge anything not pinned by a story (which can't be deleted). + conn.execute( + "DELETE FROM source_ratings WHERE source_id IN (SELECT id FROM sources WHERE domain LIKE '%.example')" + ) + conn.execute( + "DELETE FROM source_ingestion_state WHERE source_id IN (SELECT id FROM sources WHERE domain LIKE '%.example')" + ) + conn.execute( + "DELETE FROM articles WHERE source_id IN (SELECT id FROM sources WHERE domain LIKE '%.example') " + "AND id NOT IN (SELECT headline_article_id FROM stories WHERE headline_article_id IS NOT NULL)" + ) + conn.execute( + "DELETE FROM sources WHERE domain LIKE '%.example' AND id NOT IN (SELECT source_id FROM articles)" + ) diff --git a/apps/ingestion/tests/test_language.py b/apps/ingestion/tests/test_language.py index 05f1dc9..73dea3d 100644 --- a/apps/ingestion/tests/test_language.py +++ b/apps/ingestion/tests/test_language.py @@ -1,12 +1,17 @@ from language import decide_skip_reason, detect_language -class FakeModel: +class FakeFastText: def __init__(self, label: str, confidence: float): self.label, self.confidence = label, confidence - def predict(self, text, k=1): - return ([f"__label__{self.label}"], [self.confidence]) + def predict(self, text, k, threshold, on_unicode_error): + return [(self.confidence, f"__label__{self.label}")] + + +class FakeModel: + def __init__(self, label: str, confidence: float): + self.f = FakeFastText(label, confidence) def test_short_text_uses_source_language(): diff --git a/apps/ingestion/tests/test_seed_sources.py b/apps/ingestion/tests/test_seed_sources.py index deef8a1..7b4654c 100644 --- a/apps/ingestion/tests/test_seed_sources.py +++ b/apps/ingestion/tests/test_seed_sources.py @@ -6,7 +6,7 @@ DSN = db.DSN -def test_seed_inserts_five_sources_then_is_idempotent(): +def test_seed_inserts_all_sources_then_is_idempotent(): with psycopg.connect(DSN, autocommit=True) as conn: conn.execute("DELETE FROM source_ingestion_state WHERE source_id IN " "(SELECT id FROM sources WHERE domain = ANY(%s))", @@ -18,7 +18,7 @@ def test_seed_inserts_five_sources_then_is_idempotent(): first_count = seed_sources.seed(conn) second_count = seed_sources.seed(conn) - assert first_count == 5 + assert first_count == len(seed_sources.SOURCES) assert second_count == 0 with psycopg.connect(DSN, autocommit=True) as conn: diff --git a/apps/ingestion/workers/__pycache__/__init__.cpython-314.pyc b/apps/ingestion/workers/__pycache__/__init__.cpython-314.pyc deleted file mode 100644 index 5504130f441c51044b183b0ca245956580cf7b68..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 160 zcmdPq>P{wCAAftgHh(Vb_lhJP_LlF~@{~08C%T_oSU)o_J+-(bGe1wi94HA=8XupTmst`YuUAlc gi^C>2KczG$)vkyYXd1}AVi4mKGb1Bo5i^hl0M4!^ng9R* diff --git a/apps/ingestion/workers/__pycache__/scheduler.cpython-314.pyc b/apps/ingestion/workers/__pycache__/scheduler.cpython-314.pyc deleted file mode 100644 index cff94f7e807c32bdc204c15979752f0e0a902221..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 7345 zcmdTpTW}lKb$77~VDTUb0wlqwury6ZsQ8d%#!@9)3ByN3Q=*U+1zI-6%@SCWYb|%t zy9?1|8x1nkHnKVs*&TPRWIPjQCNr@o{_saXoQ^xAxOGSU=|Fsh%$iL+b^DP&Y9v(| zfA!o8EC4Zz=+R#97?(U1!yuj#!a6y2K{C9keQC2NFF4fE!6x{mfCTLG)UUA|FGd zFNUhRVW`*9daD?LdX(1N#CoVV(0co& z17hRxml4XqowcD$?Hy>KxH*c$U3Ai4B9?OjN>c{DtYAUWExepHRNXou$mzexsaP=u zYe5k-Mb5yuW~49okPcnIIo%WtT@eP8=hF*HCa1ypYsRdgTv70{@cd!Zzoc3Vf-HRf zFu+h36=61)nO7{~;Gj5kdTerJX!3$MB7E}m&P3cQ8c0ks3!;&r4(9H>G*L{oZt?KjT1CmKY*w81j zIzO-A1Z(rNvZ-jQuGoP^8SYKdWj(FfK@4E@c~df%_4G=ut}L0BoW9&wJd+ycbd)BX zP~ryUf;#XobFU35d6YskuH%0Vl!LxD^c`k_5gF0d&vc>$`+G9iW;3R4v!;dLM}U-P z&cKVZWfE}D*r)n0mAyL(T9rqmbp%L*!**cS&~hJT2})ckwW!LJ|i*dYJ!(N*+y^p?h^p<;48pcHr|NTt%@Lur-KM4}*#wKp zUDzA}XtVQ%Wda3Jw;*70aywem22yEXat7QEBa|~h#{ivo0ySHK_*x*o%B}~V%@1$; zL))Rq4ebrB(E7s1R|}`kt%s6Zp^JAy7uQ3t7N#$4hGwp@zYB!6qp@q_JACLy;Eh0` zer$t(X@?Kq@L%`8dGv=NOz!&;St+xZK(~Fotca}0^@EN}xG`Z8J5*FYlBvK6Gp5ag ztlA#9+#J?zE~BO`9E5oZ1`|#w3KBg!<$s!Wl7Uvph!8Y0(YqwOHMC2L!t5Mv7O^E5 zI*Eu66@~%G_ZJ3-eN$|Y0|Ck`yjEGa$Yeb!^f>WwR-I%dCg;krQd!eyy%oVp@>L*9 zRS-X~!@6NKdJgvD_Y+uWw-k5$W6)6`u5Iv^WIb_o_GpS_P>TDi81_`{myM(% zGwAr+fU!U@o{l&;B;|+`d!(m=p!FOUy|T$ z^{3ooAm!OmS8s_@pY7n z(1bLWO)zf+cwvR|?k{$kc3no%mo*h&XJrf4>n1$JZcSZOEjwf`fl^hllvUH0VbeON zZRXNxMad|c3FxC|F{P3%1Ji;QOqUEWpuoCHgfLOAwr|mZC&AFwwC%4}_wdADQNh#* zWO7OgS<|k01bN#x@_JgylIH=(C@jTk+ujmN@Bk28ZSNvHG-}!`Az|hg7iGL`hvCVP zC|aeSM5E1R4MW33KjA|pixbd%224a^2;n27?R&XvmKrxlTlCn&qyG<|?;T%OwO~Ulu zkc2-<#V3r1JEbSVV=%K5e&%Jcm*Cy1jo!F={pxyce6?@0_Hh36c2jeKYuJu9<tdo2QodoQ&f8Mx+s$im|P<^o$>+Zxw)b83U@+73kW6MwiLL-pfeUp{=cR54$KOaJ8{Gcw@*)ydY8fajCO!3Jpj zYqV+9>;5#*F>=KBX%7cIpB`pN{Rm6y1A$S__ix_7Xw3JB9YBmg(7F5D% z7$>W3hY|$P_e@1&i{ROcsOq%=UVgH^6=6nEm%h(TfTc$;!Ni;f+05zhyD&K|6;_kr z(Ot6n>LnsERbGt*?j43V7}47y@}Eb0d5DkRsJ&i$liT3iDfa*az;_MU;GPc_{3v$9 z{ZE0xz*ABZj{}T-i8la2BFc zs9MM582c&)A}?k=WW_K>9f@DmfnQ3?BDWDZP9hYBHgoSNwG*bxO5Nd*ESqAJ)hkLX zr#T0Nh-O}xf#A&)lXCU3z#F9~1{mItUs;ZFOp~+GG{epuAnxq3(oQMV$B1sH^(P^f z`STG-y-xwD>>N}cqSL)rE$syc~o@FZ%n0&h6D>T_^E2`{BRaRV}x zSqY=kR|*p7RM0<+52N5~K$LUAmjj$1A0i>)#IL5LF9MXZlJ+xgcM)XQGPEde4in0W zJWX`-e=LekdEoKkzbapzP-0IK(XrB@z#}ay>nE#W$5zT#`L)T~J%FJJLB(<~uj0#A z!7fJ#Dr+k-^ei(?R0&PVvd8W8AXGKeRWd8VNUt5Wl0)Lt6zW{_Th>wSDF9|17#|(@OH9chL zbLza!jZKW6qp?9u?$2h2&reYA6O*Ik2GYmF^8}urq1hgAY00Wa&5$!vwJToikJ0`| ze0DlO#S$dglCfpG)0$x_pke}5VbML(PQ{$KhD9;wh+T{S5|E+H{RUJ(wu!3~TjOHy z&i_RFk+#Vl+KF`iZ1(4KKb>2T^ybHRIR6jEzdv3GCq6i}!JUAB=Z%xsPyXGD`Qe?0 z)~$x4YYj)&8wTne{gO17 zgZL#0(i4HlS(3dnqpHzS+{h8b!U?Ts;dS03<+c_NBpyDC*A~fkz05 zUm)4nNp=D+&Kw9=Q;zXiBd93lPqMKb%hCzkvawAIu*@w#zaMbJyi! zQp^JnVmiM-q0f--GZgqA^x}V_BVV8z+JsK>{T&*(g9bkLg^T`4p|xk-*PHijH#QYe zbi1x8A9~<{e>ryR(Ks{1(1`DY59&b|f~U>}udQsb&A;LJ Nza72HBCdtv`+rbx;9LLz diff --git a/infra/tests/__pycache__/__init__.cpython-314.pyc b/infra/tests/__pycache__/__init__.cpython-314.pyc deleted file mode 100644 index fb50a1867cd303754cab2a914347f7102757279b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 149 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 Date: Sat, 10 Oct 2026 17:11:06 +0530 Subject: [PATCH 02/12] built auth api --- apps/api/Dockerfile | 8 + apps/api/package-lock.json | 2434 +++++++++++++++++++++ apps/api/package.json | 23 + apps/api/src/app.ts | 57 + apps/api/src/db.ts | 14 + apps/api/src/plugins/auth.ts | 9 + apps/api/src/plugins/cache.ts | 11 + apps/api/src/server.ts | 9 + apps/api/test/app.test.ts | 53 + apps/api/test/db.test.ts | 10 + apps/api/tsconfig.json | 15 + apps/ingestion/tests/test_seed_sources.py | 20 +- infra/docker-compose.yml | 13 + 13 files changed, 2662 insertions(+), 14 deletions(-) create mode 100644 apps/api/Dockerfile create mode 100644 apps/api/package-lock.json create mode 100644 apps/api/package.json create mode 100644 apps/api/src/app.ts create mode 100644 apps/api/src/db.ts create mode 100644 apps/api/src/plugins/auth.ts create mode 100644 apps/api/src/plugins/cache.ts create mode 100644 apps/api/src/server.ts create mode 100644 apps/api/test/app.test.ts create mode 100644 apps/api/test/db.test.ts create mode 100644 apps/api/tsconfig.json diff --git a/apps/api/Dockerfile b/apps/api/Dockerfile new file mode 100644 index 0000000..e672f0b --- /dev/null +++ b/apps/api/Dockerfile @@ -0,0 +1,8 @@ +# apps/api/Dockerfile +FROM node:22-slim +WORKDIR /app +COPY package.json package-lock.json ./ +RUN npm ci +COPY . . +RUN npm run build +CMD ["npm", "start"] diff --git a/apps/api/package-lock.json b/apps/api/package-lock.json new file mode 100644 index 0000000..e463d55 --- /dev/null +++ b/apps/api/package-lock.json @@ -0,0 +1,2434 @@ +{ + "name": "@newstack/api", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@newstack/api", + "version": "0.1.0", + "dependencies": { + "fastify": "^5.1.0", + "pg": "^8.13.1" + }, + "devDependencies": { + "@types/node": "^22.10.1", + "@types/pg": "^8.11.10", + "tsx": "^4.19.2", + "typescript": "^5.7.2", + "vitest": "^5.0.3" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@fastify/ajv-compiler": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-4.0.7.tgz", + "integrity": "sha512-VP0yIfwlc1ohj733keYDqyHm1VWeP9VQxYomunhr90jkC4aNNibbkrRiaNgPgqj1ORxUenILR4qzA/ppYBNAtQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "ajv": "^8.12.0", + "ajv-formats": "^3.0.1", + "fast-uri": "^4.0.0" + } + }, + "node_modules/@fastify/deepmerge": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@fastify/deepmerge/-/deepmerge-3.2.2.tgz", + "integrity": "sha512-155eqA/WMQdN6gUD4RrhS2K0Pcz2Ou9pQ86L0sipOpP9ONbTA6oYw/hW4VdBjR4egcDHqmWUK9x80oUX7S1mDQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/@fastify/error": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@fastify/error/-/error-4.2.1.tgz", + "integrity": "sha512-MYBumuIwP+5MIpXSyKm6/o1Eigcoij186lWUssX3LH5U8KWqU0ktZhzC4CErIX9EJeQeRGI+zJZQWakBlFbUcg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/@fastify/fast-json-stringify-compiler": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@fastify/fast-json-stringify-compiler/-/fast-json-stringify-compiler-5.1.1.tgz", + "integrity": "sha512-pyxRKk7UpUnnrztP9pF4+ZYxusEQ4LifTyzRdiXmyql1QezNHxO02D25MVU2o4kMeAMYWAjG6RKRlojgkbiDAw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "fast-json-stringify": "^7.0.0" + } + }, + "node_modules/@fastify/forwarded": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@fastify/forwarded/-/forwarded-3.1.0.tgz", + "integrity": "sha512-TaHtoQxIEkzod6SPZNPh9hSUFk8sY0MK6f710XLFVL93OtbnfnLE6w5B11KbLu0wZJ5o5XKvl6+EhhgCazRWaQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/@fastify/merge-json-schemas": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@fastify/merge-json-schemas/-/merge-json-schemas-0.2.2.tgz", + "integrity": "sha512-d/gx9sRbUCVgGRGzma46wLcwiY8noTqO2Nmjg/vbTARxHRE5bdh5PIL8s97Lt1X7ScRWRW52eVYxwIoPwIdVUA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/@fastify/proxy-addr": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/@fastify/proxy-addr/-/proxy-addr-5.1.2.tgz", + "integrity": "sha512-uqB4QsgVoro0XDC+DIftIMGXHdBPn+lK6Ek3xXAIxPMCJfqxnbtyEoMCm2KPUVOuIosd7Cyw9HawEqMFM/UA+g==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/forwarded": "^3.0.0", + "ipaddr.js": "^2.1.0" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.153.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.153.0.tgz", + "integrity": "sha512-jSNrRIyvPVLL5E1z/tS5zLAprosGz/FuI1PWwEOz59j3E4TTTzSpqKb/RDGqJhhW0QHYISGXxq582wGx6T11Tw==", + "dev": true, + "license": "MIT", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, + "node_modules/@pinojs/redact": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", + "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", + "license": "MIT" + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.13.tgz", + "integrity": "sha512-Vu/NICrgdRvUOeaJZIyB2FP63s6fO/4ud5l8dXmxDx9yDyn+mN0y+gsvsVviSli+T361Aqo0h46EuMoobrwawA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.13.tgz", + "integrity": "sha512-6LEoIrGy5vvCd2boNnv2soPSUZj/PRvhp8R/t9KlX+mIZxThIE/ZqxrGXxmuNX0FuvQFvLdZr7S4HrYpYBVhEQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.13.tgz", + "integrity": "sha512-CaJSRzlQ6Q+zkDMVA9OkVxBjVoPRCDEE3v0FSJ9kIQBgRjFApA3Si4ijhbNVUji7ZJt+PfO0e46qy4nPInHUPA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.13.tgz", + "integrity": "sha512-e4r340I5hxSzJkD3YJBxgzq/rJ3wwxwdLLb2FPt1Zpyem20nrQPT11DDEEkazwxaHumH8wpglsp/Ozgj/wT6mw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.13.tgz", + "integrity": "sha512-hoBWDZ4/K/mJKVMbsgUd1jfpA2DiOrJ54mpegObU9TBHBBrtX4FLIXJJQRDPZ/2Dn91GvPob+Z2hwKJzf4E5SQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.13.tgz", + "integrity": "sha512-oF9fZu3vTWnTiVAkDFxU9PJLt1nh20K9RRpuHsR7ZnE2L7XookBbDBVLojCVSLGbvtSlxeUNnjAj6DbNsKyzbg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.13.tgz", + "integrity": "sha512-DTpb/+jMiqWMPgphn4ksW1zW+XLJ6dJJ9FMvnIk9I291LhCctMPhMYJrP5t9p6C9qa3KXadDdUEZHBaPh3kNxQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.13.tgz", + "integrity": "sha512-xU+Kvv9N2A3SY35zFh1FWWNjxbadE/BKeqnkXe7vScU4GZsw757BlVmDLITZEq8DPpk6m7uoDYkK7P+x/uU5Ow==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.13.tgz", + "integrity": "sha512-XMk/hnhh25oQ756Lclb1Q11h4ll0xC0Esa5w4Vzp+mAE2S3AGJgeM0SonrIgY8fHhjarb6hcoTuwoFG0VVP2ig==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.13.tgz", + "integrity": "sha512-U1BoTHYmlywE2n3HUWjzZ+8EQsKr+OwpxYuDWaubx42tUL8QSL7nOqRz/NVHanibbFP3M1Rxuprz/wNcbMKjqw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.13.tgz", + "integrity": "sha512-J4cm80Pc6wEwV5AZ42o62sIQgDk+Y7R2s646jIwuvO+4V46QB58N4vVuMPWCBaNgursXLpmUWJV//WQAkPvHBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.13.tgz", + "integrity": "sha512-W0euZS40U4V6JkmEnVCIX9mcsL6cbXQ4X8c2w+MMOIjFg68TXUW+u3RT+2e1t1pv9mg/EP3pDtK3POXdQ7Rxyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.13.tgz", + "integrity": "sha512-do/AF/4n9MRshia3a5aXoy8xM2c6mgfT6D7H/dY6my5z+hfCIR4ab7mk4IRj++nsEQpKy39kd0DQnDq+r692tw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.13.tgz", + "integrity": "sha512-6FtUf2cJZobutJdxnGYX8z0vtbgqU0b2v8vfBhkXb9wp2MppMFtkunE+IaOntyi01rkIQoPW2GHABtfzsC0BRQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.13.tgz", + "integrity": "sha512-UKhFaZTeTcbKrb3iU82m17jVcPEAoqDkCN5MBqgPyZSwp/cQmib21crTS0DdH7T1B7wZRWEI89YQAOJMet5yhg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.20.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.5.tgz", + "integrity": "sha512-U2+DNr+wSjpsTS/wZGYHq7GcwfuSmKiKvoPvK22zwTlRhU91yOniN4qRR5KhIjvif7ysw/dz/hKmfDH0Ris4aA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/pg": { + "version": "8.23.1", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.23.1.tgz", + "integrity": "sha512-fKVHpikPdg4GKks3JuLEhvwSyvwzF23hnabPy6DD8ljVbC7+6J5dQzdv4arV6jqq57djnMgs1HKBxX4P8aBI3A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^2.2.0" + } + }, + "node_modules/@vitest/mocker": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.3.tgz", + "integrity": "sha512-T8sWAIbkSyAjkwTcaEc3Iu0o9A27X1/kdXrizhZkGuSKScRQtRzclfAMpOTcGdXCsqxeWlpGy3XjqaW8CpLORg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.31", + "@vitest/spy": "5.0.3", + "estree-walker": "^3.0.3", + "magic-string": "^1.2.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/spy": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.3.tgz", + "integrity": "sha512-XhFysQTB8AZ+P4gMi+Lpo99vg2AZi0qKpaB9yXQl37+CaMEAPO3iH/wGVnSyL5MPERiLezpqTVtrR6UZH5GCXg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/abstract-logging": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz", + "integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==", + "license": "MIT" + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ajv/node_modules/fast-uri": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz", + "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/avvio": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/avvio/-/avvio-9.3.1.tgz", + "integrity": "sha512-QyTKW61/qlba+cUQBm/sT9X8lri4UdomqSDwCnrnj1IvLVDRRiXRkz4CgBRWB+/cmfC7SK5WQc8T1vSdGvaUcQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/error": "^4.0.0", + "fastq": "^1.17.1" + } + }, + "node_modules/chai": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz", + "integrity": "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/cookie": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-2.0.1.tgz", + "integrity": "sha512-yuToqVvRrj6pfDXREyQAAv8SkAEk/8GS3jQRTiUMm66TVtBYmqQeoEjL2Lmq8Rpo6271vH76InTChTitEAm65w==", + "license": "MIT", + "engines": { + "node": ">=22" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fast-decode-uri-component": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz", + "integrity": "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==", + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-json-stringify": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stringify/-/fast-json-stringify-7.1.0.tgz", + "integrity": "sha512-wAhAQ+es0aH+x5OofdCHlZZFdqwtiM6lpEosaRIwfg271WYeEQ/tPcXdu5vpzlqZpbJ4WNqaW4QR5UjfG4Dc2w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/merge-json-schemas": "^0.2.0", + "ajv": "^8.12.0", + "ajv-formats": "^3.0.1", + "fast-uri": "^4.0.0", + "json-schema-ref-resolver": "^3.0.0", + "rfdc": "^1.2.0" + } + }, + "node_modules/fast-querystring": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/fast-querystring/-/fast-querystring-1.1.2.tgz", + "integrity": "sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==", + "license": "MIT", + "dependencies": { + "fast-decode-uri-component": "^1.0.1" + } + }, + "node_modules/fast-uri": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-4.2.2.tgz", + "integrity": "sha512-EwVpUMzLVNOmcWW0zi5G3JJwkopB6XXjnVvNBGfqrMMsVew2hkQWl5e/pe/CfYUeS6jt4cIO2MnamEW7v/7wPw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fastify": { + "version": "5.12.5", + "resolved": "https://registry.npmjs.org/fastify/-/fastify-5.12.5.tgz", + "integrity": "sha512-OB2k1dlxs5/NAABqeKV2FUHkSD2BbENsCak8yULVcymn3fHIPDVa9TI3SDnJSWYSllZmSYuZXy2gTnsT+Sut1A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/ajv-compiler": "^4.0.5", + "@fastify/error": "^4.0.0", + "@fastify/fast-json-stringify-compiler": "^5.0.0", + "@fastify/proxy-addr": "^5.0.0", + "abstract-logging": "^2.0.1", + "avvio": "^9.0.0", + "fast-json-stringify": "^7.0.0", + "find-my-way": "^9.6.0", + "light-my-request": "^6.0.0", + "pino": "^9.14.0 || ^10.1.0", + "process-warning": "^5.1.0", + "rfdc": "^1.3.1", + "secure-json-parse": "^4.0.0", + "semver": "^7.6.0", + "toad-cache": "^3.7.0" + } + }, + "node_modules/fastq": { + "version": "1.20.3", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.3.tgz", + "integrity": "sha512-XKv5nnLs6nLF71NgiKJLIZFLkPyIEuOselLG7ujZnGrRfQK8HpvY+WqKhAJUAdLomwVHErVS4LfxFlPq0/FTAw==", + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/find-my-way": { + "version": "9.10.0", + "resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.10.0.tgz", + "integrity": "sha512-eru+uCPQ2g+nLBkf84MHP/KyNNMar0BDy0j+i8Fb72GF9/YHBZ58q7FODUtfVV7XU9nfRUHXSmaM8NmZcIbZ4Q==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-querystring": "^1.0.0", + "safe-regex2": "^5.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/ipaddr.js": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.5.0.tgz", + "integrity": "sha512-aq+t5NAc+cS6rZQQVWC2x98CPqGtKKTMDd4Gaodv0wShnItdKg/51djkGJ1hqH+Oy0ivDftCbSLCQob8zso01w==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/json-schema-ref-resolver": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-schema-ref-resolver/-/json-schema-ref-resolver-3.0.1.tgz", + "integrity": "sha512-w+HwgljXpVwZ3IidoUR1qPZxSOQ+NRQWuVe6a5pe8XbHsNZE4UAwVPUvzZagn7yBKbJtt6UJnRFYbNTRPZI45Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/light-my-request": { + "version": "6.8.0", + "resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-6.8.0.tgz", + "integrity": "sha512-5Q8PjoSEdtGIB8fikZsTem5vgRo4F2mR6vUKTDW/H1g6MtAt8e3duQbg/QxL5yzg6BZhor2KOUm3hhWMWLTZwg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause", + "dependencies": { + "@fastify/deepmerge": "^3.1.0", + "cookie": "^2.0.0", + "process-warning": "^5.0.0", + "set-cookie-parser": "^3.0.1" + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "peer": true, + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/magic-string": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.3.tgz", + "integrity": "sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.6.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/obug": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz", + "integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/pg": { + "version": "8.23.1", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.23.1.tgz", + "integrity": "sha512-aL96AHANtWjPLDOLqnhx+ngp9+UK7ETEU8VJrDCGvsSSi/mGLcWYsS6Herg7lmaBJe4uwrfqsa7gTEFaSizDoQ==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.14.1", + "pg-pool": "^3.14.0", + "pg-protocol": "^1.16.1", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.4.1" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.1.tgz", + "integrity": "sha512-6PQbsFWZcp9EmJEwy5cGQ2La+AMWpP46lgbb8X+U/XsHIUweYDNCpeuKck5RxL2MdVFi7krbbEi5nX4Zh7JhrQ==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.1.tgz", + "integrity": "sha512-qR3kGNPBLpCNtz0evbKA0Y/MRFXwSSdT+pTJvYp/bXTcReZbvX1kzF0IyTc1QnxqF7AZbOeBhNL8R5mYQZV/MA==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz", + "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.1.tgz", + "integrity": "sha512-p9VOFMiHB/ZbJATetbg+99PxssTVSQRnyuPSQ67mN1+1KBOjZaZ83ZQzltnxPhJwSsC3nwVjJ10DVJlerbFzLg==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC", + "peer": true + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pino": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/pino/-/pino-10.4.0.tgz", + "integrity": "sha512-bk1ZMTwG/Vymx+zPoa28MhNzucFvXJWp3csrSuxuHPSMeUettaCoQ5Jvg07mtC4JGNpcfTwz41oVCnNgBK9+vA==", + "license": "MIT", + "dependencies": { + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^3.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^1.0.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^4.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz", + "integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==", + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-std-serializers": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", + "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", + "license": "MIT" + }, + "node_modules/postcss": { + "version": "8.5.29", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.29.tgz", + "integrity": "sha512-49cGhUbXj8Qenv0iTMxA1cFBzxXoctpC9Ujd77t1WcbJIr6nF/eI7g/8MgxrYldFRuAXvja7xQRwavoW7kgrxQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "nanoid": "^3.3.19", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/process-warning": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.1.1.tgz", + "integrity": "sha512-oCkNVj4OKnKEcocVBcDfiIugN1/cE6rgfT3nCtzeqZPA8+rIxMaQ0xXzIkGP8Gj2qUaP1UR7uhJ1VD8lUCpzCg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", + "license": "MIT" + }, + "node_modules/real-require": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-1.0.0.tgz", + "integrity": "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==", + "license": "MIT" + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ret": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/ret/-/ret-0.5.0.tgz", + "integrity": "sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "license": "MIT" + }, + "node_modules/rolldown": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.13.tgz", + "integrity": "sha512-/5apOkwWxtACcQtZ5WlrAsKS4JwXVThQPJZ3RqIruRY9UG9M8e0VlXN068xum/JRKA4aIUoJ13ghe/kGCvidiQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@oxc-project/types": "=0.153.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.13", + "@rolldown/binding-android-arm64": "1.2.13", + "@rolldown/binding-darwin-arm64": "1.2.13", + "@rolldown/binding-darwin-x64": "1.2.13", + "@rolldown/binding-freebsd-x64": "1.2.13", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.13", + "@rolldown/binding-linux-arm64-gnu": "1.2.13", + "@rolldown/binding-linux-arm64-musl": "1.2.13", + "@rolldown/binding-linux-ppc64-gnu": "1.2.13", + "@rolldown/binding-linux-s390x-gnu": "1.2.13", + "@rolldown/binding-linux-x64-gnu": "1.2.13", + "@rolldown/binding-linux-x64-musl": "1.2.13", + "@rolldown/binding-openharmony-arm64": "1.2.13", + "@rolldown/binding-win32-arm64-msvc": "1.2.13", + "@rolldown/binding-win32-x64-msvc": "1.2.13" + } + }, + "node_modules/safe-regex2": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.1.2.tgz", + "integrity": "sha512-tAtCGFPPP02VroDVzp4daGi6bC7jWG+41D47GKf18FeH/ubvKQHT9VmhAkvtjNOmJwqtApzWeUpEhkyEYULvmw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "ret": "~0.5.0" + }, + "bin": { + "safe-regex2": "bin/safe-regex2.js" + } + }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/secure-json-parse": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.1.tgz", + "integrity": "sha512-lnd81MQEZKjhW/Gd2TQqdd4XP6QYNdDW/EP+I7nTrFHP9y2LmaGPIHk9xsnzcOiEr1c39c/e84xFgGviQKBTRw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/set-cookie-parser": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.3.tgz", + "integrity": "sha512-xletSdAI5efyVJjsatBbzDkXl3M/Zmad3xfnQe7ZXuBB0yNwW7C2WFsNGjdRNfZunmNHQuX79xs/ACMTLCdcew==", + "license": "MIT" + }, + "node_modules/sonic-boom": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", + "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", + "dev": true, + "license": "BSD-3-Clause", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/std-env": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.3.0.tgz", + "integrity": "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==", + "dev": true, + "license": "MIT" + }, + "node_modules/thread-stream": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.2.0.tgz", + "integrity": "sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==", + "license": "MIT", + "dependencies": { + "real-require": "^1.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/tinybench": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.2.1.tgz", + "integrity": "sha512-IAbQaPJIIdhVxi0mqy9lez1wEwje0UQf9F1vwtbelEG5Nbgn0nPOxCvU+mNeNS9gyJTAhrHp74CbUaSynQpZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/tinyexec": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz", + "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/toad-cache": { + "version": "3.7.4", + "resolved": "https://registry.npmjs.org/toad-cache/-/toad-cache-3.7.4.tgz", + "integrity": "sha512-m1TdR/rvT7kgGJZhspNtXdsdYk0fddFpJJFlG5s+UkPFo6lkLoZ3YLOaovPYjq1R75NP5JfeTlSHaOsE09peCg==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/tsx": { + "version": "4.23.15", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.15.tgz", + "integrity": "sha512-Yiex1Ovn8z2xPpOWckIiysV1SSyRMY9BkLF++q0yKiDxCqRhosKfMg3janKkiLBwZ5c/YryloKwGZcrEmtwxKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "8.3.4", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.4.tgz", + "integrity": "sha512-IftLE8phJhdNRIGNEV4JCCvfNScoZQVfcXaN8TQropWGJtOHcu+iAB9pHcuGvmEb/kddw0LBe/jS8ztdC6xdiQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.7", + "postcss": "^8.5.29", + "rolldown": "~1.2.12", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.7.1", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.3.tgz", + "integrity": "sha512-xMw97S3rjdtj5dkVat7jCsqWBpvchs3RlpQctUqwJD0KkERk40vz2fJ77lDwW/Vzh/pk18eItYAzkodhSes3jQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/mocker": "5.0.3", + "chai": "^6.2.2", + "es-module-lexer": "^2.3.2", + "expect-type": "^1.4.0", + "magic-string": "^1.2.3", + "obug": "^2.1.4", + "picomatch": "^4.0.7", + "std-env": "^4.2.0", + "tinybench": "^6.1.4", + "tinyexec": "^1.3.0", + "tinyglobby": "^0.2.17", + "why-is-node-running": "3.2.1" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^22.12.0 || ^24.0.0 || >=26.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "5.0.3", + "@vitest/browser-preview": "5.0.3", + "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", + "@vitest/coverage-istanbul": "5.0.3", + "@vitest/coverage-v8": "5.0.3", + "@vitest/ui": "5.0.3", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.4.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/why-is-node-running": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-3.2.1.tgz", + "integrity": "sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ==", + "dev": true, + "license": "MIT", + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=20.11" + } + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + } + } +} diff --git a/apps/api/package.json b/apps/api/package.json new file mode 100644 index 0000000..9e9a20e --- /dev/null +++ b/apps/api/package.json @@ -0,0 +1,23 @@ +{ + "name": "@newstack/api", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "tsx watch src/server.ts", + "build": "tsc -p tsconfig.json", + "start": "node dist/server.js", + "test": "vitest run" + }, + "dependencies": { + "fastify": "^5.1.0", + "pg": "^8.13.1" + }, + "devDependencies": { + "@types/node": "^22.10.1", + "@types/pg": "^8.11.10", + "tsx": "^4.19.2", + "typescript": "^5.7.2", + "vitest": "^5.0.3" + } +} diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts new file mode 100644 index 0000000..ce1421c --- /dev/null +++ b/apps/api/src/app.ts @@ -0,0 +1,57 @@ +// Fastify instance: three route scopes with structurally separate plugins (ARCHITECTURE §10 +// "cache isolation is structural, not a convention") — /auth/*, /api/*, /api/public/*. +import Fastify, { type FastifyInstance } from "fastify"; + +import { authPlugin } from "./plugins/auth.js"; +import { cachePlugin } from "./plugins/cache.js"; + +const MUTATING_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]); + +export const WEB_ORIGIN = process.env.WEB_ORIGIN ?? "http://localhost:5173"; + +export function buildApp(): FastifyInstance { + const app = Fastify({ logger: true }); + + // CSRF defense in depth (§9): SameSite=Strict cookies plus an exact-Origin check plus a + // JSON-only content type on every state-changing request. + app.addHook("onRequest", async (request, reply) => { + if (!MUTATING_METHODS.has(request.method)) return; + if (request.headers.origin !== WEB_ORIGIN) { + reply.code(403).send({ error: "origin_mismatch" }); + return reply; + } + }); + app.addHook("preValidation", async (request, reply) => { + if (!MUTATING_METHODS.has(request.method)) return; + const contentType = request.headers["content-type"] ?? ""; + if (!contentType.startsWith("application/json")) { + reply.code(415).send({ error: "unsupported_content_type" }); + return reply; + } + }); + + app.addHook("onSend", async (request, reply, payload) => { + reply.header("Access-Control-Allow-Origin", WEB_ORIGIN); + reply.header("Access-Control-Allow-Credentials", "true"); + return payload; + }); + + app.get("/health", async () => ({ status: "ok" })); + + // /auth/* — neither plugin; CSRF checks and rate limits only (added in later tasks). + app.register(async (_authScope) => { + // Routes land in the signup/login/refresh/reset tasks. + }, { prefix: "/auth" }); + + // /api/public/* — cache plugin, no auth plugin: handlers here cannot read user identity. + app.register(async (publicScope) => { + await publicScope.register(cachePlugin); + }, { prefix: "/api/public" }); + + // /api/* — auth plugin, no cache plugin. + app.register(async (apiScope) => { + await apiScope.register(authPlugin); + }, { prefix: "/api" }); + + return app; +} diff --git a/apps/api/src/db.ts b/apps/api/src/db.ts new file mode 100644 index 0000000..b448522 --- /dev/null +++ b/apps/api/src/db.ts @@ -0,0 +1,14 @@ +// Postgres access: one pooled client, shared across requests (ARCHITECTURE §4). +import { Pool } from "pg"; + +export const DATABASE_URL = + process.env.DATABASE_URL ?? "postgresql://newstack:newstack@localhost:5432/newstack"; + +let pool: Pool | null = null; + +export function getPool(): Pool { + if (pool === null) { + pool = new Pool({ connectionString: DATABASE_URL, max: 5 }); + } + return pool; +} diff --git a/apps/api/src/plugins/auth.ts b/apps/api/src/plugins/auth.ts new file mode 100644 index 0000000..00f0d78 --- /dev/null +++ b/apps/api/src/plugins/auth.ts @@ -0,0 +1,9 @@ +// /api/* scope: verifies the access JWT and checks `av` against users.auth_version. +// Stub for the skeleton — real verification lands in the login/JWT-issuance task. +import type { FastifyInstance } from "fastify"; + +export async function authPlugin(app: FastifyInstance): Promise { + app.addHook("onRequest", async (_request, _reply) => { + // TODO: verify JWT (kid, sub, av, exp) and check av === users.auth_version. + }); +} diff --git a/apps/api/src/plugins/cache.ts b/apps/api/src/plugins/cache.ts new file mode 100644 index 0000000..bbe1813 --- /dev/null +++ b/apps/api/src/plugins/cache.ts @@ -0,0 +1,11 @@ +// /api/public/* scope: anonymous page cache (ARCHITECTURE §10). No auth plugin runs here, +// so handlers in this scope must never read user identity. Stub for the skeleton — Redis +// wiring lands in the rate-limits/Redis task. +import type { FastifyInstance } from "fastify"; + +export async function cachePlugin(app: FastifyInstance): Promise { + app.addHook("onSend", async (_request, reply, payload) => { + reply.header("Cache-Control", "public, max-age=30"); + return payload; + }); +} diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts new file mode 100644 index 0000000..dd51210 --- /dev/null +++ b/apps/api/src/server.ts @@ -0,0 +1,9 @@ +import { buildApp } from "./app.js"; + +const app = buildApp(); +const port = Number(process.env.PORT ?? 3000); + +app.listen({ port, host: "0.0.0.0" }).catch((err) => { + app.log.error(err); + process.exit(1); +}); diff --git a/apps/api/test/app.test.ts b/apps/api/test/app.test.ts new file mode 100644 index 0000000..b20b756 --- /dev/null +++ b/apps/api/test/app.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "vitest"; + +import { buildApp, WEB_ORIGIN } from "../src/app.js"; + +describe("health check", () => { + it("returns ok", async () => { + const app = buildApp(); + const res = await app.inject({ method: "GET", url: "/health" }); + expect(res.statusCode).toBe(200); + expect(res.json()).toEqual({ status: "ok" }); + await app.close(); + }); +}); + +describe("CSRF defense (§9)", () => { + it("rejects a mutating request with no Origin header", async () => { + const app = buildApp(); + const res = await app.inject({ method: "POST", url: "/api/public/noop" }); + expect(res.statusCode).toBe(403); + expect(res.json()).toEqual({ error: "origin_mismatch" }); + await app.close(); + }); + + it("rejects a mutating request with a non-JSON content type", async () => { + const app = buildApp(); + const res = await app.inject({ + method: "POST", + url: "/api/public/noop", + headers: { origin: WEB_ORIGIN, "content-type": "text/plain" }, + payload: "x", + }); + expect(res.statusCode).toBe(415); + expect(res.json()).toEqual({ error: "unsupported_content_type" }); + await app.close(); + }); + + it("does not gate a GET request on Origin or content type", async () => { + const app = buildApp(); + const res = await app.inject({ method: "GET", url: "/health" }); + expect(res.statusCode).toBe(200); + await app.close(); + }); +}); + +describe("CORS headers", () => { + it("echoes the configured web origin with credentials allowed", async () => { + const app = buildApp(); + const res = await app.inject({ method: "GET", url: "/health" }); + expect(res.headers["access-control-allow-origin"]).toBe(WEB_ORIGIN); + expect(res.headers["access-control-allow-credentials"]).toBe("true"); + await app.close(); + }); +}); diff --git a/apps/api/test/db.test.ts b/apps/api/test/db.test.ts new file mode 100644 index 0000000..77e3ab9 --- /dev/null +++ b/apps/api/test/db.test.ts @@ -0,0 +1,10 @@ +import { describe, expect, it } from "vitest"; + +import { getPool } from "../src/db.js"; + +describe("db pool", () => { + it("returns a working connection", async () => { + const result = await getPool().query("SELECT 1 AS one"); + expect(result.rows[0]).toEqual({ one: 1 }); + }); +}); diff --git a/apps/api/tsconfig.json b/apps/api/tsconfig.json new file mode 100644 index 0000000..c35bd02 --- /dev/null +++ b/apps/api/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "dist", + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true + }, + "include": ["src"] +} diff --git a/apps/ingestion/tests/test_seed_sources.py b/apps/ingestion/tests/test_seed_sources.py index 7b4654c..f91cdc5 100644 --- a/apps/ingestion/tests/test_seed_sources.py +++ b/apps/ingestion/tests/test_seed_sources.py @@ -7,18 +7,15 @@ def test_seed_inserts_all_sources_then_is_idempotent(): + # seed_sources.SOURCES is the real production list (ON CONFLICT DO NOTHING, per + # its own docstring: "safe to re-run on every deploy"). Sources are never hard-deleted + # (ARCHITECTURE invariant; no cascading FK from ingestion_runs/articles), so this test + # cannot force a blank slate on a dev DB that has already ingested against these + # sources — it only checks the idempotency property itself, not a fresh-insert count. with psycopg.connect(DSN, autocommit=True) as conn: - conn.execute("DELETE FROM source_ingestion_state WHERE source_id IN " - "(SELECT id FROM sources WHERE domain = ANY(%s))", - ([s["domain"] for s in seed_sources.SOURCES],)) - conn.execute("DELETE FROM sources WHERE domain = ANY(%s)", - ([s["domain"] for s in seed_sources.SOURCES],)) - - with psycopg.connect(DSN, autocommit=True) as conn: - first_count = seed_sources.seed(conn) + seed_sources.seed(conn) second_count = seed_sources.seed(conn) - assert first_count == len(seed_sources.SOURCES) assert second_count == 0 with psycopg.connect(DSN, autocommit=True) as conn: @@ -26,9 +23,4 @@ def test_seed_inserts_all_sources_then_is_idempotent(): "SELECT domain FROM sources WHERE domain = ANY(%s)", ([s["domain"] for s in seed_sources.SOURCES],), )} - conn.execute("DELETE FROM source_ingestion_state WHERE source_id IN " - "(SELECT id FROM sources WHERE domain = ANY(%s))", - ([s["domain"] for s in seed_sources.SOURCES],)) - conn.execute("DELETE FROM sources WHERE domain = ANY(%s)", - ([s["domain"] for s in seed_sources.SOURCES],)) assert domains == {s["domain"] for s in seed_sources.SOURCES} diff --git a/infra/docker-compose.yml b/infra/docker-compose.yml index 4783781..329a234 100644 --- a/infra/docker-compose.yml +++ b/infra/docker-compose.yml @@ -28,6 +28,19 @@ services: volumes: - ingestion_models:/app/models + api: + build: ../apps/api + environment: + DATABASE_URL: postgresql://newstack:newstack@db:5432/newstack + WEB_ORIGIN: http://localhost:5173 + PORT: "3000" + ports: + - "3000:3000" + depends_on: + db: + condition: service_healthy + restart: unless-stopped + volumes: pgdata: ingestion_models: From 5462e81eec013ba1a0fe403cbd26be9a3085c50a Mon Sep 17 00:00:00 2001 From: saran Date: Sat, 10 Oct 2026 17:27:23 +0530 Subject: [PATCH 03/12] signup flow and apis built --- apps/api/package-lock.json | 45 + apps/api/package.json | 3 +- apps/api/src/app.ts | 7 +- .../api/src/features/auth/common-passwords.ts | 13 + .../src/features/auth/common-passwords.txt | 10001 ++++++++++++++++ apps/api/src/features/auth/repo.ts | 31 + apps/api/src/features/auth/routes.ts | 21 + apps/api/src/features/auth/service.ts | 54 + apps/api/test/signup.test.ts | 87 + 9 files changed, 10257 insertions(+), 5 deletions(-) create mode 100644 apps/api/src/features/auth/common-passwords.ts create mode 100644 apps/api/src/features/auth/common-passwords.txt create mode 100644 apps/api/src/features/auth/repo.ts create mode 100644 apps/api/src/features/auth/routes.ts create mode 100644 apps/api/src/features/auth/service.ts create mode 100644 apps/api/test/signup.test.ts diff --git a/apps/api/package-lock.json b/apps/api/package-lock.json index e463d55..fefe265 100644 --- a/apps/api/package-lock.json +++ b/apps/api/package-lock.json @@ -8,6 +8,7 @@ "name": "@newstack/api", "version": "0.1.0", "dependencies": { + "argon2": "^0.41.1", "fastify": "^5.1.0", "pg": "^8.13.1" }, @@ -627,6 +628,15 @@ "url": "https://github.com/sponsors/oxc-project" } }, + "node_modules/@phc/format": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@phc/format/-/format-1.0.0.tgz", + "integrity": "sha512-m7X9U6BG2+J+R1lSOdCiITLLrxm+cWlNI3HUFA92oLO77ObGNzaKdh8pMLqdZcshtkKuV84olNNXDfMc4FezBQ==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/@pinojs/redact": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", @@ -1051,6 +1061,21 @@ ], "license": "BSD-3-Clause" }, + "node_modules/argon2": { + "version": "0.41.1", + "resolved": "https://registry.npmjs.org/argon2/-/argon2-0.41.1.tgz", + "integrity": "sha512-dqCW8kJXke8Ik+McUcMDltrbuAWETPyU6iq+4AhxqKphWi7pChB/Zgd/Tp/o8xRLbg8ksMj46F/vph9wnxpTzQ==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@phc/format": "^1.0.0", + "node-addon-api": "^8.1.0", + "node-gyp-build": "^4.8.1" + }, + "engines": { + "node": ">=16.17.0" + } + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -1711,6 +1736,26 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/node-addon-api": { + "version": "8.9.2", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.9.2.tgz", + "integrity": "sha512-VijLXbi3UACN69I0JVXJsX4tjACjNoQDgv2gTF6sx2wWEi8tkSg2eX8p5gSIFi8z2+DL3oHmY6OyKce38SDolg==", + "license": "MIT", + "engines": { + "node": "^18 || ^20 || >= 21" + } + }, + "node_modules/node-gyp-build": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", + "license": "MIT", + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, "node_modules/obug": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz", diff --git a/apps/api/package.json b/apps/api/package.json index 9e9a20e..1795ddc 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -5,11 +5,12 @@ "type": "module", "scripts": { "dev": "tsx watch src/server.ts", - "build": "tsc -p tsconfig.json", + "build": "tsc -p tsconfig.json && cp src/features/auth/common-passwords.txt dist/features/auth/common-passwords.txt", "start": "node dist/server.js", "test": "vitest run" }, "dependencies": { + "argon2": "^0.41.1", "fastify": "^5.1.0", "pg": "^8.13.1" }, diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index ce1421c..c139e38 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -2,6 +2,7 @@ // "cache isolation is structural, not a convention") — /auth/*, /api/*, /api/public/*. import Fastify, { type FastifyInstance } from "fastify"; +import { authRoutes } from "./features/auth/routes.js"; import { authPlugin } from "./plugins/auth.js"; import { cachePlugin } from "./plugins/cache.js"; @@ -38,10 +39,8 @@ export function buildApp(): FastifyInstance { app.get("/health", async () => ({ status: "ok" })); - // /auth/* — neither plugin; CSRF checks and rate limits only (added in later tasks). - app.register(async (_authScope) => { - // Routes land in the signup/login/refresh/reset tasks. - }, { prefix: "/auth" }); + // /auth/* — neither plugin; CSRF checks and rate limits only (rate limits: later task). + app.register(authRoutes, { prefix: "/auth" }); // /api/public/* — cache plugin, no auth plugin: handlers here cannot read user identity. app.register(async (publicScope) => { diff --git a/apps/api/src/features/auth/common-passwords.ts b/apps/api/src/features/auth/common-passwords.ts new file mode 100644 index 0000000..c69ae19 --- /dev/null +++ b/apps/api/src/features/auth/common-passwords.ts @@ -0,0 +1,13 @@ +// Top-10k common passwords (§9), loaded once at startup for O(1) lookup. +// Source: SecLists Common-Credentials/10k-most-common.txt (public domain word list). +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; + +const path = fileURLToPath(new URL("./common-passwords.txt", import.meta.url)); +const COMMON_PASSWORDS = new Set( + readFileSync(path, "utf8").split("\n").map((line) => line.trim()).filter(Boolean) +); + +export function isCommonPassword(password: string): boolean { + return COMMON_PASSWORDS.has(password); +} diff --git a/apps/api/src/features/auth/common-passwords.txt b/apps/api/src/features/auth/common-passwords.txt new file mode 100644 index 0000000..a93ea7f --- /dev/null +++ b/apps/api/src/features/auth/common-passwords.txt @@ -0,0 +1,10001 @@ +password +123456 +12345678 +1234 +qwerty +12345 +dragon +pussy +baseball +football +letmein +monkey +696969 +abc123 +mustang +michael +shadow +master +jennifer +111111 +2000 +jordan +superman +harley +1234567 +fuckme +hunter +fuckyou +trustno1 +ranger +buster +thomas +tigger +robert +soccer +fuck +batman +test +pass +killer +hockey +george +charlie +andrew +michelle +love +sunshine +jessica +asshole +6969 +pepper +daniel +access +123456789 +654321 +joshua +maggie +starwars +silver +william +dallas +yankees +123123 +ashley +666666 +hello +amanda +orange +biteme +freedom +computer +sexy +thunder +nicole +ginger +heather +hammer +summer +corvette +taylor +fucker +austin +1111 +merlin +matthew +121212 +golfer +cheese +princess +martin +chelsea +patrick +richard +diamond +yellow +bigdog +secret +asdfgh +sparky +cowboy +camaro +anthony +matrix +falcon +iloveyou +bailey +guitar +jackson +purple +scooter +phoenix +aaaaaa +morgan +tigers +porsche +mickey +maverick +cookie +nascar +peanut +justin +131313 +money +horny +samantha +panties +steelers +joseph +snoopy +boomer +whatever +iceman +smokey +gateway +dakota +cowboys +eagles +chicken +dick +black +zxcvbn +please +andrea +ferrari +knight +hardcore +melissa +compaq +coffee +booboo +bitch +johnny +bulldog +xxxxxx +welcome +james +player +ncc1701 +wizard +scooby +charles +junior +internet +bigdick +mike +brandy +tennis +blowjob +banana +monster +spider +lakers +miller +rabbit +enter +mercedes +brandon +steven +fender +john +yamaha +diablo +chris +boston +tiger +marine +chicago +rangers +gandalf +winter +bigtits +barney +edward +raiders +porn +badboy +blowme +spanky +bigdaddy +johnson +chester +london +midnight +blue +fishing +000000 +hannah +slayer +11111111 +rachel +sexsex +redsox +thx1138 +asdf +marlboro +panther +zxcvbnm +arsenal +oliver +qazwsx +mother +victoria +7777777 +jasper +angel +david +winner +crystal +golden +butthead +viking +jack +iwantu +shannon +murphy +angels +prince +cameron +girls +madison +wilson +carlos +hooters +willie +startrek +captain +maddog +jasmine +butter +booger +angela +golf +lauren +rocket +tiffany +theman +dennis +liverpoo +flower +forever +green +jackie +muffin +turtle +sophie +danielle +redskins +toyota +jason +sierra +winston +debbie +giants +packers +newyork +jeremy +casper +bubba +112233 +sandra +lovers +mountain +united +cooper +driver +tucker +helpme +fucking +pookie +lucky +maxwell +8675309 +bear +suckit +gators +5150 +222222 +shithead +fuckoff +jaguar +monica +fred +happy +hotdog +tits +gemini +lover +xxxxxxxx +777777 +canada +nathan +victor +florida +88888888 +nicholas +rosebud +metallic +doctor +trouble +success +stupid +tomcat +warrior +peaches +apples +fish +qwertyui +magic +buddy +dolphins +rainbow +gunner +987654 +freddy +alexis +braves +cock +2112 +1212 +cocacola +xavier +dolphin +testing +bond007 +member +calvin +voodoo +7777 +samson +alex +apollo +fire +tester +walter +beavis +voyager +peter +porno +bonnie +rush2112 +beer +apple +scorpio +jonathan +skippy +sydney +scott +red123 +power +gordon +travis +beaver +star +jackass +flyers +boobs +232323 +zzzzzz +steve +rebecca +scorpion +doggie +legend +ou812 +yankee +blazer +bill +runner +birdie +bitches +555555 +parker +topgun +asdfasdf +heaven +viper +animal +2222 +bigboy +4444 +arthur +baby +private +godzilla +donald +williams +lifehack +phantom +dave +rock +august +sammy +cool +brian +platinum +jake +bronco +paul +mark +frank +heka6w2 +copper +billy +cumshot +garfield +willow +cunt +little +carter +slut +albert +69696969 +kitten +super +jordan23 +eagle1 +shelby +america +11111 +jessie +house +free +123321 +chevy +bullshit +white +broncos +horney +surfer +nissan +999999 +saturn +airborne +elephant +marvin +shit +action +adidas +qwert +kevin +1313 +explorer +walker +police +christin +december +benjamin +wolf +sweet +therock +king +online +dickhead +brooklyn +teresa +cricket +sharon +dexter +racing +penis +gregory +0000 +teens +redwings +dreams +michigan +hentai +magnum +87654321 +nothing +donkey +trinity +digital +333333 +stella +cartman +guinness +123abc +speedy +buffalo +kitty +pimpin +eagle +einstein +kelly +nelson +nirvana +vampire +xxxx +playboy +louise +pumpkin +snowball +test123 +girl +sucker +mexico +beatles +fantasy +ford +gibson +celtic +marcus +cherry +cassie +888888 +natasha +sniper +chance +genesis +hotrod +reddog +alexande +college +jester +passw0rd +bigcock +smith +lasvegas +carmen +slipknot +3333 +death +kimberly +1q2w3e +eclipse +1q2w3e4r +stanley +samuel +drummer +homer +montana +music +aaaa +spencer +jimmy +carolina +colorado +creative +hello1 +rocky +goober +friday +bollocks +scotty +abcdef +bubbles +hawaii +fluffy +mine +stephen +horses +thumper +5555 +pussies +darkness +asdfghjk +pamela +boobies +buddha +vanessa +sandman +naughty +douglas +honda +matt +azerty +6666 +shorty +money1 +beach +loveme +4321 +simple +poohbear +444444 +badass +destiny +sarah +denise +vikings +lizard +melanie +assman +sabrina +nintendo +water +good +howard +time +123qwe +november +xxxxx +october +leather +bastard +young +101010 +extreme +hard +password1 +vincent +pussy1 +lacrosse +hotmail +spooky +amateur +alaska +badger +paradise +maryjane +poop +crazy +mozart +video +russell +vagina +spitfire +anderson +norman +eric +cherokee +cougar +barbara +long +420420 +family +horse +enigma +allison +raider +brazil +blonde +jones +55555 +dude +drowssap +jeff +school +marshall +lovely +1qaz2wsx +jeffrey +caroline +franklin +booty +molly +snickers +leslie +nipples +courtney +diesel +rocks +eminem +westside +suzuki +daddy +passion +hummer +ladies +zachary +frankie +elvis +reggie +alpha +suckme +simpson +patricia +147147 +pirate +tommy +semperfi +jupiter +redrum +freeuser +wanker +stinky +ducati +paris +natalie +babygirl +bishop +windows +spirit +pantera +monday +patches +brutus +houston +smooth +penguin +marley +forest +cream +212121 +flash +maximus +nipple +bobby +bradley +vision +pokemon +champion +fireman +indian +softball +picard +system +clinton +cobra +enjoy +lucky1 +claire +claudia +boogie +timothy +marines +security +dirty +admin +wildcats +pimp +dancer +hardon +veronica +fucked +abcd1234 +abcdefg +ironman +wolverin +remember +great +freepass +bigred +squirt +justice +francis +hobbes +kermit +pearljam +mercury +domino +9999 +denver +brooke +rascal +hitman +mistress +simon +tony +bbbbbb +friend +peekaboo +naked +budlight +electric +sluts +stargate +saints +bondage +brittany +bigman +zombie +swimming +duke +qwerty1 +babes +scotland +disney +rooster +brenda +mookie +swordfis +candy +duncan +olivia +hunting +blink182 +alicia +8888 +samsung +bubba1 +whore +virginia +general +passport +aaaaaaaa +erotic +liberty +arizona +jesus +abcd +newport +skipper +rolltide +balls +happy1 +galore +christ +weasel +242424 +wombat +digger +classic +bulldogs +poopoo +accord +popcorn +turkey +jenny +amber +bunny +mouse +007007 +titanic +liverpool +dreamer +everton +friends +chevelle +carrie +gabriel +psycho +nemesis +burton +pontiac +connor +eatme +lickme +roland +cumming +mitchell +ireland +lincoln +arnold +spiderma +patriots +goblue +devils +eugene +empire +asdfg +cardinal +brown +shaggy +froggy +qwer +kawasaki +kodiak +people +phpbb +light +54321 +kramer +chopper +hooker +honey +whynot +lesbian +lisa +baxter +adam +snake +teen +ncc1701d +qqqqqq +airplane +britney +avalon +sandy +sugar +sublime +stewart +wildcat +raven +scarface +elizabet +123654 +trucks +wolfpack +pervert +lawrence +raymond +redhead +american +alyssa +bambam +movie +woody +shaved +snowman +tiger1 +chicks +raptor +1969 +stingray +shooter +france +stars +madmax +kristen +sports +jerry +789456 +garcia +simpsons +lights +ryan +looking +chronic +alison +hahaha +packard +hendrix +perfect +service +spring +srinivas +spike +katie +252525 +oscar +brother +bigmac +suck +single +cannon +georgia +popeye +tattoo +texas +party +bullet +taurus +sailor +wolves +panthers +japan +strike +flowers +pussycat +chris1 +loverboy +berlin +sticky +marina +tarheels +fisher +russia +connie +wolfgang +testtest +mature +bass +catch22 +juice +michael1 +nigger +159753 +women +alpha1 +trooper +hawkeye +head +freaky +dodgers +pakistan +machine +pyramid +vegeta +katana +moose +tinker +coyote +infinity +inside +pepsi +letmein1 +bang +control +hercules +morris +james1 +tickle +outlaw +browns +billybob +pickle +test1 +michele +antonio +sucks +pavilion +changeme +caesar +prelude +tanner +adrian +darkside +bowling +wutang +sunset +robbie +alabama +danger +zeppelin +juan +rusty +pppppp +nick +2001 +ping +darkstar +madonna +qwe123 +bigone +casino +cheryl +charlie1 +mmmmmm +integra +wrangler +apache +tweety +qwerty12 +bobafett +simone +none +business +sterling +trevor +transam +dustin +harvey +england +2323 +seattle +ssssss +rose +harry +openup +pandora +pussys +trucker +wallace +indigo +storm +malibu +weed +review +babydoll +doggy +dilbert +pegasus +joker +catfish +flipper +valerie +herman +fuckit +detroit +kenneth +cheyenne +bruins +stacey +smoke +joey +seven +marino +fetish +xfiles +wonder +stinger +pizza +babe +pretty +stealth +manutd +gracie +gundam +cessna +longhorn +presario +mnbvcxz +wicked +mustang1 +victory +21122112 +shelly +awesome +athena +q1w2e3r4 +help +holiday +knicks +street +redneck +12341234 +casey +gizmo +scully +dragon1 +devildog +triumph +eddie +bluebird +shotgun +peewee +ronnie +angel1 +daisy +special +metallica +madman +country +impala +lennon +roscoe +omega +access14 +enterpri +miranda +search +smitty +blizzard +unicorn +tight +rick +ronald +asdf1234 +harrison +trigger +truck +danny +home +winnie +beauty +thailand +1234567890 +cadillac +castle +tyler +bobcat +buddy1 +sunny +stones +asian +freddie +chuck +butt +loveyou +norton +hellfire +hotsex +indiana +short +panzer +lonewolf +trumpet +colors +blaster +12121212 +fireball +logan +precious +aaron +elaine +jungle +atlanta +gold +corona +curtis +nikki +polaris +timber +theone +baller +chipper +orlando +island +skyline +dragons +dogs +benson +licker +goldie +engineer +kong +pencil +basketba +open +hornet +world +linda +barbie +chan +farmer +valentin +wetpussy +indians +larry +redman +foobar +travel +morpheus +bernie +target +141414 +hotstuff +photos +laura +savage +holly +rocky1 +fuck_inside +dollar +turbo +design +newton +hottie +moon +202020 +blondes +4128 +lestat +avatar +future +goforit +random +abgrtyu +jjjjjj +cancer +q1w2e3 +smiley +goldberg +express +virgin +zipper +wrinkle1 +stone +andy +babylon +dong +powers +consumer +dudley +monkey1 +serenity +samurai +99999999 +bigboobs +skeeter +lindsay +joejoe +master1 +aaaaa +chocolat +christia +birthday +stephani +tang +1234qwer +alfred +ball +98765432 +maria +sexual +maxima +77777777 +sampson +buckeye +highland +kristin +seminole +reaper +bassman +nugget +lucifer +airforce +nasty +watson +warlock +2121 +philip +always +dodge +chrissy +burger +bird +snatch +missy +pink +gang +maddie +holmes +huskers +piglet +photo +joanne +hamilton +dodger +paladin +christy +chubby +buckeyes +hamlet +abcdefgh +bigfoot +sunday +manson +goldfish +garden +deftones +icecream +blondie +spartan +julie +harold +charger +brandi +stormy +sherry +pleasure +juventus +rodney +galaxy +holland +escort +zxcvb +planet +jerome +wesley +blues +song +peace +david1 +ncc1701e +1966 +51505150 +cavalier +gambit +karen +sidney +ripper +oicu812 +jamie +sister +marie +martha +nylons +aardvark +nadine +minnie +whiskey +bing +plastic +anal +babylon5 +chang +savannah +loser +racecar +insane +yankees1 +mememe +hansolo +chiefs +fredfred +freak +frog +salmon +concrete +yvonne +zxcv +shamrock +atlantis +warren +wordpass +julian +mariah +rommel +1010 +harris +predator +sylvia +massive +cats +sammy1 +mister +stud +marathon +rubber +ding +trunks +desire +montreal +justme +faster +kathleen +irish +1999 +bertha +jessica1 +alpine +sammie +diamonds +tristan +00000 +swinger +shan +stallion +pitbull +letmein2 +roberto +ready +april +palmer +ming +shadow1 +audrey +chong +clitoris +wang +shirley +fuckers +jackoff +bluesky +sundance +renegade +hollywoo +151515 +bernard +wolfman +soldier +picture +pierre +ling +goddess +manager +nikita +sweety +titans +hang +fang +ficken +niners +bottom +bubble +hello123 +ibanez +webster +sweetpea +stocking +323232 +tornado +lindsey +content +bruce +buck +aragorn +griffin +chen +campbell +trojan +christop +newman +wayne +tina +rockstar +father +geronimo +pascal +crimson +brooks +hector +penny +anna +google +camera +chandler +fatcat +lovelove +cody +cunts +waters +stimpy +finger +cindy +wheels +viper1 +latin +robin +greenday +987654321 +creampie +brendan +hiphop +willy +snapper +funtime +duck +trombone +adult +cotton +cookies +kaiser +mulder +westham +latino +jeep +ravens +aurora +drizzt +madness +energy +kinky +314159 +sophia +stefan +slick +rocker +55555555 +freeman +french +mongoose +speed +dddddd +hong +henry +hungry +yang +catdog +cheng +ghost +gogogo +randy +tottenha +curious +butterfl +mission +january +singer +sherman +shark +techno +lancer +lalala +autumn +chichi +orion +trixie +clifford +delta +bobbob +bomber +holden +kang +kiss +1968 +spunky +liquid +mary +beagle +granny +network +bond +kkkkkk +millie +1973 +biggie +beetle +teacher +susan +toronto +anakin +genius +dream +cocks +dang +bush +karate +snakes +bangkok +callie +fuckyou2 +pacific +daytona +kelsey +infantry +skywalke +foster +felix +sailing +raistlin +vanhalen +huang +herbert +jacob +blackie +tarzan +strider +sherlock +lang +gong +sang +dietcoke +ultimate +tree +shai +sprite +ting +artist +chai +chao +devil +python +ninja +misty +ytrewq +sweetie +superfly +456789 +tian +jing +jesus1 +freedom1 +dian +drpepper +potter +chou +darren +hobbit +violet +yong +shen +phillip +maurice +gloria +nolimit +mylove +biscuit +yahoo +shasta +sex4me +smoker +smile +pebbles +pics +philly +tong +tintin +lesbians +marlin +cactus +frank1 +tttttt +chun +danni +emerald +showme +pirates +lian +dogg +colleen +xiao +xian +tazman +tanker +patton +toshiba +richie +alberto +gotcha +graham +dillon +rang +emily +keng +jazz +bigguy +yuan +woman +tomtom +marion +greg +chaos +fossil +flight +racerx +tuan +creamy +boss +bobo +musicman +warcraft +window +blade +shuang +sheila +shun +lick +jian +microsoft +rong +allen +feng +getsome +sally +quality +kennedy +morrison +1977 +beng +wwwwww +yoyoyo +zhang +seng +teddy +joanna +andreas +harder +luke +qazxsw +qian +cong +chuan +deng +nang +boeing +keeper +western +isabelle +1963 +subaru +sheng +thuglife +teng +jiong +miao +martina +mang +maniac +pussie +tracey +a1b2c3 +clayton +zhou +zhuang +xing +stonecol +snow +spyder +liang +jiang +memphis +regina +ceng +magic1 +logitech +chuang +dark +million +blow +sesame +shao +poison +titty +terry +kuan +kuai +kyle +mian +guan +hamster +guai +ferret +florence +geng +duan +pang +maiden +quan +velvet +nong +neng +nookie +buttons +bian +bingo +biao +zhong +zeng +xiong +zhun +ying +zong +xuan +zang +0.0.000 +suan +shei +shui +sharks +shang +shua +small +peng +pian +piao +liao +meng +miami +reng +guang +cang +change +ruan +diao +luan +lucas +qing +chui +chuo +cuan +nuan +ning +heng +huan +kansas +muscle +monroe +weng +whitney +1passwor +bluemoon +zhui +zhua +xiang +zheng +zhen +zhei +zhao +zhan +yomama +zhai +zhuo +zuan +tarheel +shou +shuo +tiao +lady +leonard +leng +kuang +jiao +13579 +basket +qiao +qiong +qiang +chuai +nian +niao +niang +huai +22222222 +bianca +zhuan +zhuai +shuan +shuai +stardust +jumper +margaret +archie +66666666 +66668888 +charlott +forget +qwertz +bones +history +milton +waterloo +2002 +stuff +11223344 +office +oldman +preston +trains +murray +vertigo +246810 +black1 +swallow +smiles +standard +alexandr +parrot +luther +user +nicolas +1976 +surfing +pioneer +pete +masters +apple1 +asdasd +auburn +hannibal +frontier +panama +lucy +buffy +brianna +welcome1 +vette +blue22 +shemale +111222 +baggins +groovy +global +turner +181818 +1979 +blades +spanking +life +byteme +lobster +collins +dawg +hilton +japanese +1970 +1964 +2424 +polo +markus +coco +deedee +mikey +1972 +171717 +1701 +strip +jersey +green1 +capital +sasha +sadie +putter +vader +seven7 +lester +marcel +banshee +grendel +gilbert +dicks +dead +hidden +iloveu +1980 +sound +ledzep +michel +147258 +female +bugger +buffett +bryan +hell +kristina +molson +2020 +wookie +sprint +thanks +jericho +102030 +grace +fuckin +mandy +ranger1 +trebor +deepthroat +bonehead +molly1 +mirage +models +1984 +2468 +stuart +showtime +squirrel +pentium +mario +anime +gator +powder +twister +connect +neptune +bruno +butts +engine +eatshit +mustangs +woody1 +shogun +septembe +pooh +jimbo +roger +annie +bacon +center +russian +sabine +damien +mollie +voyeur +2525 +363636 +leonardo +camel +chair +germany +giant +qqqq +nudist +bone +sleepy +tequila +megan +fighter +garrett +dominic +obiwan +makaveli +vacation +walnut +1974 +ladybug +cantona +ccbill +satan +rusty1 +passwor1 +columbia +napoleon +dusty +kissme +motorola +william1 +1967 +zzzz +skater +smut +play +matthew1 +robinson +valley +coolio +dagger +boner +bull +horndog +jason1 +blake +penguins +rescue +griffey +8j4ye3uz +californ +champs +qwertyuiop +portland +queen +colt45 +boat +xxxxxxx +xanadu +tacoma +mason +carpet +gggggg +safety +palace +italia +stevie +picturs +picasso +thongs +tempest +ricardo +roberts +asd123 +hairy +foxtrot +gary +nimrod +hotboy +343434 +1111111 +asdfghjkl +goose +overlord +blood +wood +stranger +454545 +shaolin +sooners +socrates +spiderman +peanuts +maxine +rogers +13131313 +andrew1 +filthy +donnie +ohyeah +africa +national +kenny +keith +monique +intrepid +jasmin +pickles +assass +fright +potato +darwin +hhhhhh +kingdom +weezer +424242 +pepsi1 +throat +romeo +gerard +looker +puppy +butch +monika +suzanne +sweets +temple +laurie +josh +megadeth +analsex +nymets +ddddddd +bigballs +support +stick +today +down +oakland +oooooo +qweasd +chucky +bridge +carrot +chargers +discover +dookie +condor +night +butler +hoover +horny1 +isabella +sunrise +sinner +jojo +megapass +martini +assfuck +grateful +ffffff +abigail +esther +mushroom +janice +jamaica +wright +sims +space +there +timmy +7654321 +77777 +cccccc +gizmodo +roxanne +ralph +tractor +cristina +dance +mypass +hongkong +helena +1975 +blue123 +pissing +thomas1 +redred +rich +basketball +attack +cash +satan666 +drunk +dixie +dublin +bollox +kingkong +katrina +miles +1971 +22222 +272727 +sexx +penelope +thompson +anything +bbbb +battle +grizzly +passat +porter +tracy +defiant +bowler +knickers +monitor +wisdom +wild +slappy +thor +letsgo +robert1 +feet +rush +brownie +hudson +098765 +playing +playtime +lightnin +melvin +atomic +bart +hawk +goku +glory +llllll +qwaszx +cosmos +bosco +knights +bentley +beast +slapshot +lewis +assword +frosty +gillian +sara +dumbass +mallard +dddd +deanna +elwood +wally +159357 +titleist +angelo +aussie +guest +golfing +doobie +loveit +chloe +elliott +werewolf +vipers +janine +1965 +blabla +surf +sucking +tardis +serena +shelley +thegame +legion +rebels +fernando +fast +gerald +sarah1 +double +onelove +loulou +toto +crash +blackcat +0007 +tacobell +soccer1 +jedi +manuel +method +river +chase +ludwig +poopie +derrick +boob +breast +kittycat +isabel +belly +pikachu +thunder1 +thankyou +jose +celeste +celtics +frances +frogger +scoobydo +sabbath +coltrane +budman +willis +jackal +bigger +zzzzz +silvia +sooner +licking +gopher +geheim +lonestar +primus +pooper +newpass +brasil +heather1 +husker +element +moomoo +beefcake +zzzzzzzz +tammy +shitty +smokin +personal +jjjj +anthony1 +anubis +backup +gorilla +fuckface +painter +lowrider +punkrock +traffic +claude +daniela +dale +delta1 +nancy +boys +easy +kissing +kelley +wendy +theresa +amazon +alan +fatass +dodgeram +dingdong +malcolm +qqqqqqqq +breasts +boots +honda1 +spidey +poker +temp +johnjohn +miguel +147852 +archer +asshole1 +dogdog +tricky +crusader +weather +syracuse +spankme +speaker +meridian +amadeus +back +harley1 +falcons +dorothy +turkey50 +kenwood +keyboard +ilovesex +1978 +blackman +shazam +shalom +lickit +jimbob +richmond +roller +carson +check +fatman +funny +garbage +sandiego +loving +magnus +cooldude +clover +mobile +bell +payton +plumber +texas1 +tool +topper +jenna +mariners +rebel +harmony +caliente +celica +fletcher +german +diana +oxford +osiris +orgasm +punkin +porsche9 +tuesday +close +breeze +bossman +kangaroo +billie +latinas +judith +astros +scruffy +donna +qwertyu +davis +hearts +kathy +jammer +java +springer +rhonda +ricky +1122 +goodtime +chelsea1 +freckles +flyboy +doodle +city +nebraska +bootie +kicker +webmaster +vulcan +iverson +191919 +blueeyes +stoner +321321 +farside +rugby +director +pussy69 +power1 +bobbie +hershey +hermes +monopoly +west +birdman +blessed +blackjac +southern +peterpan +thumbs +lawyer +melinda +fingers +fuckyou1 +rrrrrr +a1b2c3d4 +coke +nicola +bohica +heart +elvis1 +kids +blacky +stories +sentinel +snake1 +phoebe +jesse +richard1 +1234abcd +guardian +candyman +fisting +scarlet +dildo +pancho +mandingo +lucky7 +condom +munchkin +billyboy +summer1 +student +sword +skiing +sergio +site +sony +thong +rootbeer +assassin +cassidy +frederic +fffff +fitness +giovanni +scarlett +durango +postal +achilles +dawn +dylan +kisses +warriors +imagine +plymouth +topdog +asterix +hallo +cameltoe +fuckfuck +bridget +eeeeee +mouth +weird +will +sithlord +sommer +toby +theking +juliet +avenger +backdoor +goodbye +chevrole +faith +lorraine +trance +cosworth +brad +houses +homers +eternity +kingpin +verbatim +incubus +1961 +blond +zaphod +shiloh +spurs +station +jennie +maynard +mighty +aliens +hank +charly +running +dogman +omega1 +printer +aggies +chocolate +deadhead +hope +javier +bitch1 +stone55 +pineappl +thekid +lizzie +rockets +ashton +camels +formula +forrest +rosemary +oracle +rain +pussey +porkchop +abcde +clancy +nellie +mystic +inferno +blackdog +steve1 +pauline +alexander +alice +alfa +grumpy +flames +scream +lonely +puffy +proxy +valhalla +unreal +cynthia +herbie +engage +yyyyyy +010101 +solomon +pistol +melody +celeb +flying +gggg +santiago +scottie +oakley +portugal +a12345 +newbie +mmmm +venus +1qazxsw2 +beverly +zorro +work +writer +stripper +sebastia +spread +phil +tobias +links +members +metal +1221 +andre +565656 +funfun +trojans +again +cyber +hurrican +moneys +1x2zkg8w +zeus +thing +tomato +lion +atlantic +celine +usa123 +trans +account +aaaaaaa +homerun +hyperion +kevin1 +blacks +44444444 +skittles +sean +hastings +fart +gangbang +fubar +sailboat +older +oilers +craig +conrad +church +damian +dean +broken +buster1 +hithere +immortal +sticks +pilot +peters +lexmark +jerkoff +maryland +anders +cheers +possum +columbus +cutter +muppet +beautiful +stolen +swordfish +sport +sonic +peter1 +jethro +rockon +asdfghj +pass123 +paper +pornos +ncc1701a +bootys +buttman +bonjour +escape +1960 +becky +bears +362436 +spartans +tinman +threesom +lemons +maxmax +1414 +bbbbb +camelot +chad +chewie +gogo +fusion +saint +dilligaf +nopass +myself +hustler +hunter1 +whitey +beast1 +yesyes +spank +smudge +pinkfloy +patriot +lespaul +annette +hammers +catalina +finish +formula1 +sausage +scooter1 +orioles +oscar1 +over +colombia +cramps +natural +eating +exotic +iguana +bella +suckers +strong +sheena +start +slave +pearl +topcat +lancelot +angelica +magelan +racer +ramona +crunch +british +button +eileen +steph +456123 +skinny +seeking +rockhard +chief +filter +first +freaks +sakura +pacman +poontang +dalton +newlife +homer1 +klingon +watcher +walleye +tasha +tasty +sinatra +starship +steel +starbuck +poncho +amber1 +gonzo +grover +catherin +carol +candle +firefly +goblin +scotch +diver +usmc +huskies +eleven +kentucky +kitkat +israel +beckham +bicycle +yourmom +studio +tara +33333333 +shane +splash +jimmy1 +reality +12344321 +caitlin +focus +sapphire +mailman +raiders1 +clark +ddddd +hopper +excalibu +more +wilbur +illini +imperial +phillips +lansing +maxx +gothic +golfball +carlton +camille +facial +front242 +macdaddy +qwer1234 +vectra +cowboys1 +crazy1 +dannyboy +jane +betty +benny +bennett +leader +martinez +aquarius +barkley +hayden +caught +franky +ffff +floyd +sassy +pppp +pppppppp +prodigy +clarence +noodle +eatpussy +vortex +wanking +beatrice +billy1 +siemens +pedro +phillies +research +groups +carolyn +chevy1 +cccc +fritz +gggggggg +doughboy +dracula +nurses +loco +madrid +lollipop +trout +utopia +chrono +cooler +conner +nevada +wibble +werner +summit +marco +marilyn +1225 +babies +capone +fugazi +panda +mama +qazwsxed +puppies +triton +9876 +command +nnnnnn +ernest +momoney +iforgot +wolfie +studly +shawn +renee +alien +hamburg +81fukkc +741852 +catman +china +forgot +gagging +scott1 +drew +oregon +qweqwe +train +crazybab +daniel1 +cutlass +brothers +holes +heidi +mothers +music1 +what +walrus +1957 +bigtime +bike +xtreme +simba +ssss +rookie +angie +bathing +fresh +sanchez +rotten +maestro +luis +look +turbo1 +99999 +butthole +hhhh +elijah +monty +bender +yoda +shania +shock +phish +thecat +rightnow +reagan +baddog +asia +greatone +gateway1 +randall +abstr +napster +brian1 +bogart +high +hitler +emma +kill +weaver +wildfire +jackson1 +isaiah +1981 +belinda +beaner +yoyo +0.0.0.000 +super1 +select +snuggles +slutty +some +phoenix1 +technics +toon +raven1 +rayray +123789 +1066 +albion +greens +fashion +gesperrt +santana +paint +powell +credit +darling +mystery +bowser +bottle +brucelee +hehehe +kelly1 +mojo +1998 +bikini +woofwoof +yyyy +strap +sites +spears +theodore +julius +richards +amelia +central +f**k +nyjets +punisher +username +vanilla +twisted +bryant +brent +bunghole +here +elizabeth +erica +kimber +viagra +veritas +pony +pool +titts +labtec +lifetime +jenny1 +masterbate +mayhem +redbull +govols +gremlin +505050 +gmoney +rupert +rovers +diamond1 +lorenzo +trident +abnormal +davidson +deskjet +cuddles +nice +bristol +karina +milano +vh5150 +jarhead +1982 +bigbird +bizkit +sixers +slider +star69 +starfish +penetration +tommy1 +john316 +meghan +michaela +market +grant +caligula +carl +flicks +films +madden +railroad +cosmo +cthulhu +bradford +br0d3r +military +bearbear +swedish +spawn +patrick1 +polly +these +todd +reds +anarchy +groove +franco +fuckher +oooo +tyrone +vegas +airbus +cobra1 +christine +clips +delete +duster +kitty1 +mouse1 +monkeys +jazzman +1919 +262626 +swinging +stroke +stocks +sting +pippen +labrador +jordan1 +justdoit +meatball +females +saturday +park +vector +cooter +defender +desert +demon +nike +bubbas +bonkers +english +kahuna +wildman +4121 +sirius +static +piercing +terror +teenage +leelee +marissa +microsof +mechanic +robotech +rated +hailey +chaser +sanders +salsero +nuts +macross +quantum +rachael +tsunami +universe +daddy1 +cruise +nguyen +newpass6 +nudes +hellyeah +vernon +1959 +zaq12wsx +striker +sixty +steele +spice +spectrum +smegma +thumb +jjjjjjjj +mellow +astrid +cancun +cartoon +sabres +samiam +pants +oranges +oklahoma +lust +coleman +denali +nude +noodles +buzz +brest +hooter +mmmmmmmm +warthog +bloody +blueblue +zappa +wolverine +sniffing +lance +jean +jjjjj +harper +calico +freee +rover +door +pooter +closeup +bonsai +evelyn +emily1 +kathryn +keystone +iiii +1955 +yzerman +theboss +tolkien +jill +megaman +rasta +bbbbbbbb +bean +handsome +hal9000 +goofy +gringo +gofish +gizmo1 +samsam +scuba +onlyme +tttttttt +corrado +clown +clapton +deborah +boris +bulls +vivian +jayhawk +bethany +wwww +sharky +seeker +ssssssss +somethin +pillow +thesims +lighter +lkjhgf +melissa1 +marcius2 +barry +guiness +gymnast +casey1 +goalie +godsmack +doug +lolo +rangers1 +poppy +abby +clemson +clipper +deeznuts +nobody +holly1 +elliot +eeee +kingston +miriam +belle +yosemite +sucked +sex123 +sexy69 +pic\'s +tommyboy +lamont +meat +masterbating +marianne +marc +gretzky +happyday +frisco +scratch +orchid +orange1 +manchest +quincy +unbelievable +aberdeen +dawson +nathalie +ne1469 +boxing +hill +korn +intercourse +161616 +1985 +ziggy +supersta +stoney +senior +amature +barber +babyboy +bcfields +goliath +hack +hardrock +children +frodo +scout +scrappy +rosie +qazqaz +tracker +active +craving +commando +cohiba +deep +cyclone +dana +bubba69 +katie1 +mpegs +vsegda +jade +irish1 +better +sexy1 +sinclair +smelly +squerting +lions +jokers +jeanette +julia +jojojo +meathead +ashley1 +groucho +cheetah +champ +firefox +gandalf1 +packer +magnolia +love69 +tyler1 +typhoon +tundra +bobby1 +kenworth +village +volley +beth +wolf359 +0420 +000007 +swimmer +skydive +smokes +patty +peugeot +pompey +legolas +kristy +redhot +rodman +redalert +having +grapes +4runner +carrera +floppy +dollars +ou8122 +quattro +adams +cloud9 +davids +nofear +busty +homemade +mmmmm +whisper +vermont +webmaste +wives +insertion +jayjay +philips +phone +topher +tongue +temptress +midget +ripken +havefun +gretchen +canon +celebrity +five +getting +ghetto +direct +otto +ragnarok +trinidad +usnavy +conover +cruiser +dalshe +nicole1 +buzzard +hottest +kingfish +misfit +moore +milfnew +warlord +wassup +bigsexy +blackhaw +zippy +shearer +tights +thursday +kungfu +labia +journey +meatloaf +marlene +rider +area51 +batman1 +bananas +636363 +cancel +ggggg +paradox +mack +lynn +queens +adults +aikido +cigars +nova +hoosier +eeyore +moose1 +warez +interacial +streaming +313131 +pertinant +pool6123 +mayday +rivers +revenge +animated +banker +baddest +gordon24 +ccccc +fortune +fantasies +touching +aisan +deadman +homepage +ejaculation +whocares +iscool +jamesbon +1956 +1pussy +womam +sweden +skidoo +spock +sssss +petra +pepper1 +pinhead +micron +allsop +amsterda +army +aside +gunnar +666999 +chip +foot +fowler +february +face +fletch +george1 +sapper +science +sasha1 +luckydog +lover1 +magick +popopo +public +ultima +derek +cypress +booker +businessbabe +brandon1 +edwards +experience +vulva +vvvv +jabroni +bigbear +yummy +010203 +searay +secret1 +showing +sinbad +sexxxx +soleil +software +piccolo +thirteen +leopard +legacy +jensen +justine +memorex +marisa +mathew +redwing +rasputin +134679 +anfield +greenbay +gore +catcat +feather +scanner +pa55word +contortionist +danzig +daisy1 +hores +erik +exodus +vinnie +iiiiii +zero +1001 +subway +tank +second +snapple +sneakers +sonyfuck +picks +poodle +test1234 +their +llll +junebug +june +marker +mellon +ronaldo +roadkill +amanda1 +asdfjkl +beaches +greene +great1 +cheerleaers +force +doitnow +ozzy +madeline +radio +tyson +christian +daphne +boxster +brighton +housewifes +emmanuel +emerson +kkkk +mnbvcx +moocow +vides +wagner +janet +1717 +bigmoney +blonds +1000 +storys +stereo +4545 +420247 +seductive +sexygirl +lesbean +live +justin1 +124578 +animals +balance +hansen +cabbage +canadian +gangbanged +dodge1 +dimas +lori +loud +malaka +puss +probes +adriana +coolman +crawford +dante +nacked +hotpussy +erotica +kool +mirror +wearing +implants +intruder +bigass +zenith +woohoo +womans +tanya +tango +stacy +pisces +laguna +krystal +maxell +andyod22 +barcelon +chainsaw +chickens +flash1 +downtown +orgasms +magicman +profit +pusyy +pothead +coconut +chuckie +contact +clevelan +designer +builder +budweise +hotshot +horizon +hole +experienced +mondeo +wifes +1962 +strange +stumpy +smiths +sparks +slacker +piper +pitchers +passwords +laptop +jeremiah +allmine +alliance +bbbbbbb +asscock +halflife +grandma +hayley +88888 +cecilia +chacha +saratoga +sandy1 +santos +doogie +number +positive +qwert40 +transexual +crow +close-up +darrell +bonita +ib6ub9 +volvo +jacob1 +iiiii +beastie +sunnyday +stoned +sonics +starfire +snapon +pictuers +pepe +testing1 +tiberius +lisalisa +lesbain +litle +retard +ripple +austin1 +badgirl +golfgolf +flounder +garage +royals +dragoon +dickie +passwor +ocean +majestic +poppop +trailers +dammit +nokia +bobobo +br549 +emmitt +knock +minime +mikemike +whitesox +1954 +3232 +353535 +seamus +solo +sparkle +sluttey +pictere +titten +lback +1024 +angelina +goodluck +charlton +fingerig +gallaries +goat +ruby +passme +oasis +lockerroom +logan1 +rainman +twins +treasure +absolutely +club +custom +cyclops +nipper +bucket +homepage- +hhhhh +momsuck +indain +2345 +beerbeer +bimmer +susanne +stunner +stevens +456456 +shell +sheba +tootsie +tiny +testerer +reefer +really +1012 +harcore +gollum +545454 +chico +caveman +carole +fordf150 +fishes +gaymen +saleen +doodoo +pa55w0rd +looney +presto +qqqqq +cigar +bogey +brewer +helloo +dutch +kamikaze +monte +wasser +vietnam +visa +japanees +0123 +swords +slapper +peach +jump +marvel +masterbaiting +march +redwood +rolling +1005 +ametuer +chiks +cathy +callaway +fucing +sadie1 +panasoni +mamas +race +rambo +unknown +absolut +deacon +dallas1 +housewife +kristi +keywest +kirsten +kipper +morning +wings +idiot +18436572 +1515 +beating +zxczxc +sullivan +303030 +shaman +sparrow +terrapin +jeffery +masturbation +mick +redfish +1492 +angus +barrett +goirish +hardcock +felicia +forfun +galary +freeporn +duchess +olivier +lotus +pornographic +ramses +purdue +traveler +crave +brando +enter1 +killme +moneyman +welder +windsor +wifey +indon +yyyyy +stretch +taylor1 +4417 +shopping +picher +pickup +thumbnils +johnboy +jets +jess +maureen +anne +ameteur +amateurs +apollo13 +hambone +goldwing +5050 +charley +sally1 +doghouse +padres +pounding +quest +truelove +underdog +trader +crack +climber +bolitas +bravo +hohoho +model +italian +beanie +beretta +wrestlin +stroker +tabitha +sherwood +sexyman +jewels +johannes +mets +marcos +rhino +bdsm +balloons +goodman +grils +happy123 +flamingo +games +route66 +devo +dino +outkast +paintbal +magpie +llllllll +twilight +critter +christie +cupcake +nickel +bullseye +krista +knickerless +mimi +murder +videoes +binladen +xerxes +slim +slinky +pinky +peterson +thanatos +meister +menace +ripley +retired +albatros +balloon +bank +goten +5551212 +getsdown +donuts +divorce +nwo4life +lord +lost +underwear +tttt +comet +deer +damnit +dddddddd +deeznutz +nasty1 +nonono +nina +enterprise +eeeee +misfit99 +milkman +vvvvvv +isaac +1818 +blueboy +beans +bigbutt +wyatt +tech +solution +poetry +toolman +laurel +juggalo +jetski +meredith +barefoot +50spanks +gobears +scandinavian +original +truman +cubbies +nitram +briana +ebony +kings +warner +bilbo +yumyum +zzzzzzz +stylus +321654 +shannon1 +server +secure +silly +squash +starman +steeler +staples +phrases +techniques +laser +135790 +allan +barker +athens +cbr600 +chemical +fester +gangsta +fucku2 +freeze +game +salvador +droopy +objects +passwd +lllll +loaded +louis +manchester +losers +vedder +clit +chunky +darkman +damage +buckshot +buddah +boobed +henti +hillary +webber +winter1 +ingrid +bigmike +beta +zidane +talon +slave1 +pissoff +person +thegreat +living +lexus +matador +readers +riley +roberta +armani +ashlee +goldstar +5656 +cards +fmale +ferris +fuking +gaston +fucku +ggggggg +sauron +diggler +pacers +looser +pounded +premier +pulled +town +trisha +triangle +cornell +collin +cosmic +deeper +depeche +norway +bright +helmet +kristine +kendall +mustard +misty1 +watch +jagger +bertie +berger +word +3x7pxr +silver1 +smoking +snowboar +sonny +paula +penetrating +photoes +lesbens +lambert +lindros +lillian +roadking +rockford +1357 +143143 +asasas +goodboy +898989 +chicago1 +card +ferrari1 +galeries +godfathe +gawker +gargoyle +gangster +rubble +rrrr +onetime +pussyman +pooppoop +trapper +twenty +abraham +cinder +company +newcastl +boricua +bunny1 +boxer +hotred +hockey1 +hooper +edward1 +evan +kris +misery +moscow +milk +mortgage +bigtit +show +snoopdog +three +lionel +leanne +joshua1 +july +1230 +assholes +cedric +fallen +farley +gene +frisky +sanity +script +divine +dharma +lucky13 +property +tricia +akira +desiree +broadway +butterfly +hunt +hotbox +hootie +heat +howdy +earthlink +karma +kiteboy +motley +westwood +1988 +bert +blackbir +biggles +wrench +working +wrestle +slippery +pheonix +penny1 +pianoman +tomorrow +thedude +jenn +jonjon +jones1 +mattie +memory +micheal +roadrunn +arrow +attitude +azzer +seahawks +diehard +dotcom +lola +tunafish +chivas +cinnamon +clouds +deluxe +northern +nuclear +north +boom +boobie +hurley +krishna +momomo +modles +volume +23232323 +bluedog +wwwwwww +zerocool +yousuck +pluto +limewire +link +joung +marcia +awnyce +gonavy +haha +films+pic+galeries +fabian +francois +girsl +fuckthis +girfriend +rufus +drive +uncencored +a123456 +airport +clay +chrisbln +combat +cygnus +cupoi +never +netscape +brett +hhhhhhhh +eagles1 +elite +knockers +kendra +mommy +1958 +tazmania +shonuf +piano +pharmacy +thedog +lips +jillian +jenkins +midway +arsenal1 +anaconda +australi +gromit +gotohell +787878 +66666 +carmex2 +camber +gator1 +ginger1 +fuzzy +seadoo +dorian +lovesex +rancid +uuuuuu +911911 +nature +bulldog1 +helen +health +heater +higgins +kirk +monalisa +mmmmmmm +whiteout +virtual +ventura +jamie1 +japanes +james007 +2727 +2469 +blam +bitchass +believe +zephyr +stiffy +sweet1 +silent +southpar +spectre +tigger1 +tekken +lenny +lakota +lionking +jjjjjjj +medical +megatron +1369 +hawaiian +gymnastic +golfer1 +gunners +7779311 +515151 +famous +glass +screen +rudy +royal +sanfran +drake +optimus +panther1 +love1 +mail +maggie1 +pudding +venice +aaron1 +delphi +niceass +bounce +busted +house1 +killer1 +miracle +momo +musashi +jammin +2003 +234567 +wp2003wp +submit +silence +sssssss +state +spikes +sleeper +passwort +toledo +kume +media +meme +medusa +mantis +remote +reading +reebok +1017 +artemis +hampton +harry1 +cafc91 +fettish +friendly +oceans +oooooooo +mango +ppppp +trainer +troy +uuuu +909090 +cross +death1 +news +bullfrog +hokies +holyshit +eeeeeee +mitch +jasmine1 +& +& +sergeant +spinner +leon +jockey +records +right +babyblue +hans +gooner +474747 +cheeks +cars +candice +fight +glow +pass1234 +parola +okokok +pablo +magical +major +ramsey +poseidon +989898 +confused +circle +crusher +cubswin +nnnn +hollywood +erin +kotaku +milo +mittens +whatsup +vvvvv +iomega +insertions +bengals +bermuda +biit +yellow1 +012345 +spike1 +south +sowhat +pitures +peacock +pecker +theend +juliette +jimmie +romance +augusta +hayabusa +hawkeyes +castro +florian +geoffrey +dolly +lulu +qaz123 +usarmy +twinkle +cloud +chuckles +cold +hounddog +hover +hothot +europa +ernie +kenshin +kojak +mikey1 +water1 +196969 +because +wraith +zebra +wwwww +33333 +simon1 +spider1 +snuffy +philippe +thunderb +teddy1 +lesley +marino13 +maria1 +redline +renault +aloha +antoine +handyman +cerberus +gamecock +gobucks +freesex +duffman +ooooo +papa +nuggets +magician +longbow +preacher +porno1 +county +chrysler +contains +dalejr +darius +darlene +dell +navy +buffy1 +hedgehog +hoosiers +honey1 +hott +heyhey +europe +dutchess +everest +wareagle +ihateyou +sunflowe +3434 +senators +shag +spoon +sonoma +stalker +poochie +terminal +terefon +laurence +maradona +maryann +marty +roman +1007 +142536 +alibaba +america1 +bartman +astro +goth +century +chicken1 +cheater +four +ghost1 +passpass +oral +r2d2c3po +civic +cicero +myxworld +kkkkk +missouri +wishbone +infiniti +jameson +1a2b3c +1qwerty +wonderboy +skip +shojou +stanford +sparky1 +smeghead +poiuy +titanium +torres +lantern +jelly +jeanne +meier +1213 +bayern +basset +gsxr750 +cattle +charlene +fishing1 +fullmoon +gilles +dima +obelix +popo +prissy +ramrod +unique +absolute +bummer +hotone +dynasty +entry +konyor +missy1 +moses +282828 +yeah +xyz123 +stop +426hemi +404040 +seinfeld +simmons +pingpong +lazarus +matthews +marine1 +manning +recovery +12345a +beamer +babyface +greece +gustav +7007 +charity +camilla +ccccccc +faggot +foxy +frozen +gladiato +duckie +dogfood +paranoid +packers1 +longjohn +radical +tuna +clarinet +claudio +circus +danny1 +novell +nights +bonbon +kashmir +kiki +mortimer +modelsne +moondog +monaco +vladimir +insert +1953 +zxc123 +supreme +3131 +sexxx +selena +softail +poipoi +pong +together +mars +martin1 +rogue +alone +avalanch +audia4 +55bgates +cccccccc +chick +came11 +figaro +geneva +dogboy +dnsadm +dipshit +paradigm +othello +operator +officer +malone +post +rafael +valencia +tripod +choice +chopin +coucou +coach +cocksuck +common +creature +borussia +book +browning +heritage +hiziad +homerj +eight +earth +millions +mullet +whisky +jacques +store +4242 +speedo +starcraf +skylar +spaceman +piggy +pierce +tiger2 +legos +lala +jezebel +judy +joker1 +mazda +barton +baker +727272 +chester1 +fishman +food +rrrrrrrr +sandwich +dundee +lumber +magazine +radar +ppppppp +tranny +aaliyah +admiral +comics +cleo +delight +buttfuck +homeboy +eternal +kilroy +kellie +khan +violin +wingman +walmart +bigblue +blaze +beemer +beowulf +bigfish +yyyyyyy +woodie +yeahbaby +0123456 +tbone +style +syzygy +starter +lemon +linda1 +merlot +mexican +11235813 +anita +banner +bangbang +badman +barfly +grease +carla +charles1 +ffffffff +screw +doberman +diane +dogshit +overkill +counter +coolguy +claymore +demons +demo +nomore +normal +brewster +hhhhhhh +hondas +iamgod +enterme +everett +electron +eastside +kayla +minimoni +mybaby +wildbill +wildcard +ipswich +200000 +bearcat +zigzag +yyyyyyyy +xander +sweetnes +369369 +skyler +skywalker +pigeon +peyton +tipper +lilly +asdf123 +alphabet +asdzxc +babybaby +banane +barnes +guyver +graphics +grand +chinook +florida1 +flexible +fuckinside +otis +ursitesux +tototo +trust +tower +adam12 +christma +corey +chrome +buddie +bombers +bunker +hippie +keegan +misfits +vickie +292929 +woofer +wwwwwwww +stubby +sheep +secrets +sparta +stang +spud +sporty +pinball +jorge +just4fun +johanna +maxxxx +rebecca1 +gunther +fatima +fffffff +freeway +garion +score +rrrrr +sancho +outback +maggot +puddin +trial +adrienne +987456 +colton +clyde +brain +brains +hoops +eleanor +dwayne +kirby +mydick +villa +19691969 +bigcat +becker +shiner +silverad +spanish +templar +lamer +juicy +marsha +mike1 +maximum +rhiannon +real +1223 +10101010 +arrows +andres +alucard +baldwin +baron +avenue +ashleigh +haggis +channel +cheech +safari +ross +dog123 +orion1 +paloma +qwerasdf +presiden +vegitto +trees +969696 +adonis +colonel +cookie1 +newyork1 +brigitte +buddyboy +hellos +heineken +dwight +eraser +kerstin +motion +moritz +millwall +visual +jaybird +1983 +beautifu +bitter +yvette +zodiac +steven1 +sinister +slammer +smashing +slick1 +sponge +teddybea +theater +this +ticklish +lipstick +jonny +massage +mann +reynolds +ring +1211 +amazing +aptiva +applepie +bailey1 +guitar1 +chanel +canyon +gagged +fuckme1 +rough +digital1 +dinosaur +punk +98765 +90210 +clowns +cubs +daniels +deejay +nigga +naruto +boxcar +icehouse +hotties +electra +kent +widget +india +insanity +1986 +2004 +best +bluefish +bingo1 +***** +stratus +strength +sultan +storm1 +44444 +4200 +sentnece +season +sexyboy +sigma +smokie +spam +point +pippo +ticket +temppass +joel +manman +medicine +1022 +anton +almond +bacchus +aztnm +axio +awful +bamboo +hakr +gregor +hahahaha +5678 +casanova +caprice +camero1 +fellow +fountain +dupont +dolphin1 +dianne +paddle +magnet +qwert1 +pyon +porsche1 +tripper +vampires +coming +noway +burrito +bozo +highheel +hughes +hookem +eddie1 +ellie +entropy +kkkkkkkk +kkkkkkk +illinois +jacobs +1945 +1951 +24680 +21212121 +100000 +stonecold +taco +subzero +sharp +sexxxy +skolko +shanna +skyhawk +spurs1 +sputnik +piazza +testpass +letter +lane +kurt +jiggaman +matilda +1224 +harvard +hannah1 +525252 +4ever +carbon +chef +federico +ghosts +gina +scorpio1 +rt6ytere +madison1 +loki +raquel +promise +coolness +christina +coldbeer +citadel +brittney +highway +evil +monarch +morgan1 +washingt +1997 +bella1 +berry +yaya +yolanda +superb +taxman +studman +stephanie +3636 +sherri +sheriff +shepherd +poland +pizzas +tiffany1 +toilet +latina +lassie +larry1 +joseph1 +mephisto +meagan +marian +reptile +rico +razor +1013 +barron +hammer1 +gypsy +grande +carroll +camper +chippy +cat123 +call +chimera +fiesta +glock +glenn +domain +dieter +dragonba +onetwo +nygiants +odessa +password2 +louie +quartz +prowler +prophet +towers +ultra +cocker +corleone +dakota1 +cumm +nnnnnnn +natalia +boxers +hugo +heynow +hollow +iceberg +elvira +kittykat +kate +kitchen +wasabi +vikings1 +impact +beerman +string +sleep +splinter +snoopy1 +pipeline +pocket +legs +maple +mickey1 +manuela +mermaid +micro +meowmeow +redbird +alisha +baura +battery +grass +chevys +chestnut +caravan +carina +charmed +fraser +frogman +diving +dogger +draven +drifter +oatmeal +paris1 +longdong +quant4307s +rachel1 +vegitta +cole +cobras +corsair +dadada +noelle +mylife +nine +bowwow +body +hotrats +eastwood +moonligh +modena +wave +illusion +iiiiiii +jayhawks +birgit +zone +sutton +susana +swingers +shocker +shrimp +sexgod +squall +stefanie +squeeze +soul +patrice +poiu +players +tigers1 +toejam +tickler +line +julie1 +jimbo1 +jefferso +juanita +michael2 +rodeo +robot +1023 +annie1 +bball +guess +happy2 +charter +farm +flasher +falcon1 +fiction +fastball +gadget +scrabble +diaper +dirtbike +dinner +oliver1 +partner +paco +lucille +macman +poopy +popper +postman +ttttttt +ursula +acura +cowboy1 +conan +daewoo +cyrus +customer +nation +nemrac58 +nnnnn +nextel +bolton +bobdylan +hopeless +eureka +extra +kimmie +kcj9wx5n +killbill +musica +volkswag +wage +windmill +wert +vintage +iloveyou1 +itsme +bessie +zippo +311311 +starligh +smokey1 +spot +snappy +soulmate +plasma +thelma +tonight +krusty +just4me +mcdonald +marius +rochelle +rebel1 +1123 +alfredo +aubrey +audi +chantal +fick +goaway +roses +sales +rusty2 +dirt +dogbone +doofus +ooooooo +oblivion +mankind +luck +mahler +lllllll +pumper +puck +pulsar +valkyrie +tupac +compass +concorde +costello +cougars +delaware +niceguy +nocturne +bob123 +boating +bronze +hopkins +herewego +hewlett +houhou +hubert +earnhard +eeeeeeee +keller +mingus +mobydick +venture +verizon +imation +1950 +1948 +1949 +223344 +bigbig +blossom +zack +wowwow +sissy +skinner +spiker +square +snooker +sluggo +player1 +junk +jeannie +jsbach +jumbo +jewel +medic +robins +reddevil +reckless +123456a +1125 +1031 +beacon +astra +gumby +hammond +hassan +757575 +585858 +chillin +fuck1 +sander +lowell +radiohea +upyours +trek +courage +coolcool +classics +choochoo +darryl +nikki1 +nitro +bugs +boytoy +ellen +excite +kirsty +kane +wingnut +wireless +icu812 +1master +beatle +bigblock +blanca +wolfen +summer99 +sugar1 +tartar +sexysexy +senna +sexman +sick +someone +soprano +pippin +platypus +pixies +telephon +land +laura1 +laurent +rimmer +road +report +1020 +12qwaszx +arturo +around +hamish +halifax +fishhead +forum +dododo +doit +outside +paramedi +lonesome +mandy1 +twist +uuuuu +uranus +ttttt +butcher +bruce1 +helper +hopeful +eduard +dusty1 +kathy1 +katherin +moonbeam +muscles +monster1 +monkeybo +morton +windsurf +vvvvvvv +vivid +install +1947 +187187 +1941 +1952 +tatiana +susan1 +31415926 +sinned +sexxy +senator +sebastian +shadows +smoothie +snowflak +playstat +playa +playboy1 +toaster +jerry1 +marie1 +mason1 +merlin1 +roger1 +roadster +112358 +1121 +andrea1 +bacardi +auto +hardware +hardy +789789 +5555555 +captain1 +flores +fergus +sascha +rrrrrrr +dome +onion +nutter +lololo +qqqqqqq +quick +undertak +uuuuuuuu +uuuuuuu +criminal +cobain +cindy1 +coors +dani +descent +nimbus +nomad +nanook +norwich +bomb +bombay +broker +hookup +kiwi +winners +jackpot +1a2b3c4d +1776 +beardog +bighead +blast +bird33 +0987 +stress +shot +spooge +pelican +peepee +perry +pointer +titan +thedoors +jeremy1 +annabell +altima +baba +hallie +hate +hardone +5454 +candace +catwoman +flip +faithful +finance +farmboy +farscape +genesis1 +salomon +destroy +papers +option +page +loser1 +lopez +r2d2 +pumpkins +training +chriss +cumcum +ninjas +ninja1 +hung +erika +eduardo +killers +miller1 +islander +jamesbond +intel +jarvis +19841984 +2626 +bizzare +blue12 +biker +yoyoma +sushi +styles +shitface +series +shanti +spanker +steffi +smart +sphinx +please1 +paulie +pistons +tiburon +limited +maxwell1 +mdogg +rockies +armstron +alexia +arlene +alejandr +arctic +banger +audio +asimov +augustus +grandpa +753951 +4you +chilly +care1839 +chapman +flyfish +fantasia +freefall +santa +sandrine +oreo +ohshit +macbeth +madcat +loveya +mallory +rage +quentin +qwerqwer +project +ramirez +colnago +citizen +chocha +cobalt +crystal1 +dabears +nevets +nineinch +broncos1 +helene +huge +edgar +epsilon +easter +kestrel +moron +virgil +winston1 +warrior1 +iiiiiiii +iloveyou2 +1616 +beat +bettina +woowoo +zander +straight +shower +sloppy +specialk +tinkerbe +jellybea +reader +romero +redsox1 +ride +1215 +1112 +annika +arcadia +answer +baggio +base +guido +555666 +carmel +cayman +cbr900rr +chips +gabriell +gertrude +glennwei +roxy +sausages +disco +pass1 +luna +lovebug +macmac +queenie +puffin +vanguard +trip +trinitro +airwolf +abbott +aaa111 +cocaine +cisco +cottage +dayton +deadly +datsun +bricks +bumper +eldorado +kidrock +wizard1 +whiskers +wind +wildwood +istheman +interest +italy +25802580 +benoit +bigones +woodland +wolfpac +strawber +suicide +3030 +sheba1 +sixpack +peace1 +physics +pearson +tigger2 +toad +megan1 +meow +ringo +roll +amsterdam +717171 +686868 +5424 +catherine +canuck +football1 +footjob +fulham +seagull +orgy +lobo +mancity +truth +trace +vancouve +vauxhall +acidburn +derf +myspace1 +boozer +buttercu +howell +hola +easton +minemine +munch +jared +1dragon +biology +bestbuy +bigpoppa +blackout +blowfish +bmw325 +bigbob +stream +talisman +tazz +sundevil +3333333 +skate +shutup +shanghai +shop +spencer1 +slowhand +polish +pinky1 +tootie +thecrow +leroy +jonathon +jubilee +jingle +martine +matrix1 +manowar +michaels +messiah +mclaren +resident +reilly +redbaron +rollins +romans +return +rivera +andromed +athlon +beach1 +badgers +guitars +harald +harddick +gotribe +6996 +7grout +5wr2i7h8 +635241 +chase1 +carver +charlotte +fallout +fiddle +fredrick +fenris +francesc +fortuna +ferguson +fairlane +felipe +felix1 +forward +gasman +frost +fucks +sahara +sassy1 +dogpound +dogbert +divx1 +manila +loretta +priest +pornporn +quasar +venom +987987 +access1 +clippers +daylight +decker +daman +data +dentist +crusty +nathan1 +nnnnnnnn +bruno1 +bucks +brodie +budapest +kittens +kerouac +mother1 +waldo1 +wedding +whistler +whatwhat +wanderer +idontkno +1942 +1946 +bigdawg +bigpimp +zaqwsx +414141 +3000gt +434343 +shoes +serpent +starr +smurf +pasword +tommie +thisisit +lake +john1 +robotics +redeye +rebelz +1011 +alatam +asses +asians +bama +banzai +harvest +gonzalez +hair +hanson +575757 +5329 +cascade +chinese +fatty +fender1 +flower2 +funky +sambo +drummer1 +dogcat +dottie +oedipus +osama +macleod +prozac +private1 +rampage +punch +presley +concord +cook +cinema +cornwall +cleaner +christopher +ciccio +corinne +clutch +corvet07 +daemon +bruiser +boiler +hjkl +eyes +egghead +expert +ethan +kasper +mordor +wasted +jamess +iverson3 +bluesman +zouzou +090909 +1002 +switch +stone1 +4040 +sisters +sexo +shawna +smith1 +sperma +sneaky +polska +thewho +terminat +krypton +lawson +library +lekker +jules +johnson1 +johann +justus +rockie +romano +aspire +bastards +goodie +cheese1 +fenway +fishon +fishin +fuckoff1 +girls1 +sawyer +dolores +desmond +duane +doomsday +pornking +ramones +rabbits +transit +aaaaa1 +clock +delilah +noel +boyz +bookworm +bongo +bunnies +brady +buceta +highbury +henry1 +heels +eastern +krissy +mischief +mopar +ministry +vienna +weston +wildone +vodka +jayson +bigbooty +beavis1 +betsy +xxxxxx1 +yogibear +000001 +0815 +zulu +420000 +september +sigmar +sprout +stalin +peggy +patch +lkjhgfds +lagnaf +rolex +redfox +referee +123123123 +1231 +angus1 +ariana +ballin +attila +hall +greedy +grunt +747474 +carpedie +cecile +caramel +foxylady +field +gatorade +gidget +futbol +frosch +saiyan +schmidt +drums +donner +doggy1 +drum +doudou +pack +pain +nutmeg +quebec +valdepen +trash +triple +tosser +tuscl +track +comfort +choke +comein +cola +deputy +deadpool +bremen +borders +bronson +break +hotass +hotmail1 +eskimo +eggman +koko +kieran +katrin +kordell1 +komodo +mone +munich +vvvvvvvv +winger +jaeger +ivan +jackson5 +2222222 +bergkamp +bennie +bigben +zanzibar +worm +xxx123 +sunny1 +373737 +services +sheridan +slater +slayer1 +snoop +stacie +peachy +thecure +times +little1 +jennaj +marquis +middle +rasta69 +1114 +aries +havana +gratis +calgary +checkers +flanker +salope +dirty1 +draco +dogface +luv2epus +rainbow6 +qwerty123 +umpire +turnip +vbnm +tucson +troll +aileen +codered +commande +damon +nana +neon +nico +nightwin +neil +boomer1 +bushido +hotmail0 +horace +enternow +kaitlyn +keepout +karen1 +mindy +mnbv +viewsoni +volcom +wizards +wine +1995 +berkeley +bite +zach +woodstoc +tarpon +shinobi +starstar +phat +patience +patrol +toolbox +julien +johnny1 +joebob +marble +riders +reflex +120676 +1235 +angelus +anthrax +atlas +hawks +grandam +harlem +hawaii50 +gorgeous +655321 +cabron +challeng +callisto +firewall +firefire +fischer +flyer +flower1 +factory +federal +gambler +frodo1 +funk +sand +sam123 +scania +dingo +papito +passmast +olive +palermo +ou8123 +lock +ranch +pride +randy1 +twiggy +travis1 +transfer +treetop +addict +admin1 +963852 +aceace +clarissa +cliff +cirrus +clifton +colin +bobdole +bonner +bogus +bonjovi +bootsy +boater +elway7 +edison +kelvin +kenny1 +moonshin +montag +moreno +wayne1 +white1 +jazzy +jakejake +1994 +1991 +2828 +blunt +bluejays +beau +belmont +worthy +systems +sensei +southpark +stan +peeper +pharao +pigpen +tomahawk +teensex +leedsutd +larkin +jermaine +jeepster +jimjim +josephin +melons +marlon +matthias +marriage +robocop +1003 +1027 +antelope +azsxdc +gordo +hazard +granada +8989 +7894 +ceasar +cabernet +cheshire +california +chelle +candy1 +fergie +fanny +fidelio +giorgio +fuckhead +ruth +sanford +diego +dominion +devon +panic +longer +mackie +qawsed +trucking +twelve +chloe1 +coral +daddyo +nostromo +boyboy +booster +bucky +honolulu +esquire +dynamite +motor +mollydog +wilder +windows1 +waffle +wallet +warning +virus +washburn +wealth +vincent1 +jabber +jaguars +javelin +irishman +idefix +bigdog1 +blue42 +blanked +blue32 +biteme1 +bearcats +blaine +yessir +sylveste +team +stephan +sunfire +tbird +stryker +3ip76k2 +sevens +sheldon +pilgrim +tenchi +titman +leeds +lithium +lander +linkin +landon +marijuan +mariner +markie +midnite +reddwarf +1129 +123asd +12312312 +allstar +albany +asdf12 +antonia +aspen +hardball +goldfing +7734 +49ers +carlo +chambers +cable +carnage +callum +carlos1 +fitter +fandango +festival +flame +gofast +gamma +fucmy69 +scrapper +dogwood +django +magneto +loose +premium +addison +9999999 +abc1234 +cromwell +newyear +nichole +bookie +burns +bounty +brown1 +bologna +earl +entrance +elway +killjoy +kerry +keenan +kick +klondike +mini +mouser +mohammed +wayer +impreza +irene +insomnia +24682468 +2580 +24242424 +billbill +bellaco +blessing +blues1 +bedford +blanco +blunts +stinks +teaser +streets +sf49ers +shovel +solitude +spikey +sonia +pimpdadd +timeout +toffee +lefty +johndoe +johndeer +mega +manolo +mentor +margie +ratman +ridge +record +rhodes +robin1 +1124 +1210 +1028 +1226 +another +babylove +barbados +harbor +gramma +646464 +carpente +chaos1 +fishbone +fireblad +glasgow +frogs +scissors +screamer +salem +scuba1 +ducks +driven +doggies +dicky +donovan +obsidian +rams +progress +tottenham +aikman +comanche +corolla +clarke +conway +cumslut +cyborg +dancing +boston1 +bong +houdini +helmut +elvisp +edge +keksa12 +misha +monty1 +monsters +wetter +watford +wiseguy +veronika +visitor +janelle +1989 +1987 +20202020 +biatch +beezer +bigguns +blueball +bitchy +wyoming +yankees2 +wrestler +stupid1 +sealteam +sidekick +simple1 +smackdow +sporting +spiral +smeller +sperm +plato +tophat +test2 +theatre +thick +toomuch +leigh +jello +jewish +junkie +maxim +maxime +meadow +remingto +roofer +124038 +1018 +1269 +1227 +123457 +arkansas +alberta +aramis +andersen +beaker +barcelona +baltimor +googoo +goochi +852456 +4711 +catcher +carman +champ1 +chess +fortress +fishfish +firefigh +geezer +rsalinas +samuel1 +saigon +scooby1 +doors +dick1 +devin +doom +dirk +doris +dontknow +load +magpies +manfred +raleigh +vader1 +universa +tulips +defense +mygirl +burn +bowtie +bowman +holycow +heinrich +honeys +enforcer +katherine +minerva +wheeler +witch +waterboy +jaime +irving +1992 +23skidoo +bimbo +blue11 +birddog +woodman +womble +zildjian +030303 +stinker +stoppedby +sexybabe +speakers +slugger +spotty +smoke1 +polopolo +perfect1 +things +torpedo +tender +thrasher +lakeside +lilith +jimmys +jerk +junior1 +marsh +masamune +rice +root +1214 +april1 +allgood +bambi +grinch +767676 +5252 +cherries +chipmunk +cezer121 +carnival +capecod +finder +flint +fearless +goats +funstuff +gideon +savior +seabee +sandro +schalke +salasana +disney1 +duckman +options +pancake +pantera1 +malice +lookin +love123 +lloyd +qwert123 +puppet +prayers +union +tracer +crap +creation +cwoui +nascar24 +hookers +hollie +hewitt +estrella +erection +ernesto +ericsson +edthom +kaylee +kokoko +kokomo +kimball +morales +mooses +monk +walton +weekend +inter +internal +1michael +1993 +19781978 +25252525 +worker +summers +surgery +shibby +shamus +skibum +sheepdog +sex69 +spliff +slipper +spoons +spanner +snowbird +slow +toriamos +temp123 +tennesse +lakers1 +jomama +julio +mazdarx7 +rosario +recon +riddle +room +revolver +1025 +1101 +barney1 +babycake +baylor +gotham +gravity +hallowee +hancock +616161 +515000 +caca +cannabis +castor +chilli +fdsa +getout +fuck69 +gators1 +sail +sable +rumble +dolemite +dork +dickens +duffer +dodgers1 +painting +onions +logger +lorena +lookout +magic32 +port +poon +prime +twat +coventry +citroen +christmas +civicsi +cocksucker +coochie +compaq1 +nancy1 +buzzer +boulder +butkus +bungle +hogtied +honor +hero +hotgirls +hilary +heidi1 +eggplant +mustang6 +mortal +monkey12 +wapapapa +wendy1 +volleyba +vibrate +vicky +bledsoe +blink +birthday4 +woof +xxxxx1 +talk +stephen1 +suburban +stock +tabatha +sheeba +start1 +soccer10 +something +starcraft +soccer12 +peanut1 +plastics +penthous +peterbil +tools +tetsuo +torino +tennis1 +termite +ladder +last +lemmein +lakewood +jughead +melrose +megane +reginald +redone +request +angela1 +alive +alissa +goodgirl +gonzo1 +golden1 +gotyoass +656565 +626262 +capricor +chains +calvin1 +foolish +fallon +getmoney +godfather +gabber +gilligan +runaway +salami +dummy +dungeon +dudedude +dumb +dope +opus +paragon +oxygen +panhead +pasadena +opendoor +odyssey +magellan +lottie +printing +pressure +prince1 +trustme +christa +court +davies +neville +nono +bread +buffet +hound +kajak +killkill +mona +moto +mildred +winner1 +vixen +whiteboy +versace +winona +voyager1 +instant +indy +jackjack +bigal +beech +biggun +blake1 +blue99 +big1 +woods +synergy +success1 +336699 +sixty9 +shark1 +skin +simba1 +sharpe +sebring +spongebo +spunk +springs +sliver +phialpha +password9 +pizza1 +plane +perkins +pookey +tickling +lexingky +lawman +joe123 +jolly +mike123 +romeo1 +redheads +reserve +apple123 +alanis +ariane +antony +backbone +aviation +band +hand +green123 +haley +carlitos +byebye +cartman1 +camden +chewy +camaross +favorite6 +forumwp +franks +ginscoot +fruity +sabrina1 +devil666 +doughnut +pantie +oldone +paintball +lumina +rainbow1 +prosper +total +true +umbrella +ajax +951753 +achtung +abc12345 +compact +color +corn +complete +christi +closer +corndog +deerhunt +darklord +dank +nimitz +brandy1 +bowl +breanna +holidays +hetfield +holein1 +hillbill +hugetits +east +evolutio +kenobi +whiplash +waldo +wg8e3wjf +wing +istanbul +invis +1996 +benton +bigjohn +bluebell +beef +beater +benji +bluejay +xyzzy +wrestling +storage +superior +suckdick +taichi +stellar +stephane +shaker +skirt +seymour +semper +splurge +squeak +pearls +playball +pitch +phyllis +pooky +piss +tomas +titfuck +joemama +johnny5 +marcello +marjorie +married +maxi +rhubarb +rockwell +ratboy +reload +rooney +redd +1029 +1030 +1220 +anchor +bbking +baritone +gryphon +gone +57chevy +494949 +celeron +fishy +gladiator +fucker1 +roswell +dougie +downer +dicker +diva +domingo +donjuan +nympho +omar +praise +racers +trick +trauma +truck1 +trample +acer +corwin +cricket1 +clemente +climax +denmark +cuervo +notnow +nittany +neutron +native +bosco1 +buffa +breaker +hello2 +hydro +estelle +exchange +explore +kisskiss +kittys +kristian +montecar +modem +mississi +mooney +weiner +washington +20012001 +bigdick1 +bibi +benfica +yahoo1 +striper +tabasco +supra +383838 +456654 +seneca +serious +shuttle +socks +stanton +penguin1 +pathfind +testibil +thethe +listen +lightning +lighting +jeter2 +marma +mark1 +metoo +republic +rollin +redleg +redbone +redskin +rocco +1245 +armand +anthony7 +altoids +andrews +barley +away +asswipe +bauhaus +bbbbbb1 +gohome +harrier +golfpro +goldeney +818181 +6666666 +5000 +5rxypn +cameron1 +calling +checker +calibra +fields +freefree +faith1 +fist +fdm7ed +finally +giraffe +glasses +giggles +fringe +gate +georgie +scamper +rrpass1 +screwyou +duffy +deville +dimples +pacino +ontario +passthie +oberon +quest1 +postov1000 +puppydog +puffer +raining +protect +qwerty7 +trey +tribe +ulysses +tribal +adam25 +a1234567 +compton +collie +cleopatr +contract +davide +norris +namaste +myrtle +buffalo1 +bonovox +buckley +bukkake +burning +burner +bordeaux +burly +hun999 +emilie +elmo +enters +enrique +keisha +mohawk +willard +vgirl +whale +vince +jayden +jarrett +1812 +1943 +222333 +bigjim +bigd +zoom +wordup +ziggy1 +yahooo +workout +young1 +written +xmas +zzzzzz1 +surfer1 +strife +sunlight +tasha1 +skunk +shauna +seth +soft +sprinter +peaches1 +planes +pinetree +plum +pimping +theforce +thedon +toocool +leeann +laddie +list +lkjh +lara +joke +jupiter1 +mckenzie +matty +rene +redrose +1200 +102938 +annmarie +alexa +antares +austin31 +ground +goose1 +737373 +78945612 +789987 +6464 +calimero +caster +casper1 +cement +chevrolet +chessie +caddy +chill +child +canucks +feeling +favorite +fellatio +f00tball +francine +gateway2 +gigi +gamecube +giovanna +rugby1 +scheisse +dshade +dudes +dixie1 +owen +offshore +olympia +lucas1 +macaroni +manga +pringles +puff +tribble +trouble1 +ussy +core +clint +coolhand +colonial +colt +debra +darthvad +dealer +cygnusx1 +natalie1 +newark +husband +hiking +errors +eighteen +elcamino +emmett +emilia +koolaid +knight1 +murphy1 +volcano +idunno +2005 +2233 +block +benito +blueberr +biguns +yamahar1 +zapper +zorro1 +0911 +3006 +sixsix +shopper +siobhan +sextoy +stafford +snowboard +speedway +sounds +pokey +peabody +playboy2 +titi +think +toast +toonarmy +lister +lambda +joecool +jonas +joyce +juniper +mercer +max123 +manny +massimo +mariposa +met2002 +reggae +ricky1 +1236 +1228 +1016 +all4one +arianna +baberuth +asgard +gonzales +484848 +5683 +6669 +catnip +chiquita +charisma +capslock +cashmone +chat +figure +galant +frenchy +gizmodo1 +girlies +gabby +garner +screwy +doubled +divers +dte4uw +done +dragonfl +maker +locks +rachelle +treble +twinkie +trailer +tropical +acid +crescent +cooking +cococo +cory +dabomb +daffy +dandfa +cyrano +nathanie +briggs +boners +helium +horton +hoffman +hellas +espresso +emperor +killa +kikimora +wanda +w4g8at +verona +ilikeit +iforget +1944 +20002000 +birthday1 +beatles1 +blue1 +bigdicks +beethove +blacklab +blazers +benny1 +woodwork +0069 +0101 +taffy +susie +survivor +swim +stokes +4567 +shodan +spoiled +steffen +pissed +pavlov +pinnacle +place +petunia +terrell +thirty +toni +tito +teenie +lemonade +lily +lillie +lalakers +lebowski +lalalala +ladyboy +jeeper +joyjoy +mercury1 +mantle +mannn +rocknrol +riversid +reeves +123aaa +11112222 +121314 +1021 +1004 +1120 +allen1 +ambers +amstel +ambrose +alice1 +alleycat +allegro +ambrosia +alley +australia +hatred +gspot +graves +goodsex +hattrick +harpoon +878787 +8inches +4wwvte +cassandr +charlie123 +case +chavez +fighting +gabriela +gatsby +fudge +gerry +generic +gareth +fuckme2 +samm +sage +seadog +satchmo +scxakv +santafe +dipper +dingle +dizzy +outoutout +madmad +london1 +qbg26i +pussy123 +randolph +vaughn +tzpvaw +vamp +comedy +comp +cowgirl +coldplay +dawgs +delaney +nt5d27 +novifarm +needles +notredam +newness +mykids +bryan1 +bouncer +hihihi +honeybee +iceman1 +herring +horn +hook +hotlips +dynamo +klaus +kittie +kappa +kahlua +muffy +mizzou +mohamed +musical +wannabe +wednesda +whatup +weller +waterfal +willy1 +invest +blanche +bear1 +billabon +youknow +zelda +yyyyyy1 +zachary1 +01234567 +070462 +zurich +superstar +storms +tail +stiletto +strat +427900 +sigmachi +shelter +shells +sexy123 +smile1 +sophie1 +stefano +stayout +somerset +smithers +playmate +pinkfloyd +phish1 +payday +thebear +telefon +laetitia +kswbdu +larson +jetta +jerky +melina +metro +revoluti +retire +respect +1216 +1201 +1204 +1222 +1115 +archange +barry1 +handball +676767 +chandra +chewbacc +flesh +furball +gocubs +fruit +fullback +gman +gentle +dunbar +dewalt +dominiqu +diver1 +dhip6a +olemiss +ollie +mandrake +mangos +pretzel +pusssy +tripleh +valdez +vagabond +clean +comment +crew +clovis +deaths +dandan +csfbr5yy +deadspin +darrel +ninguna +noah +ncc74656 +bootsie +bp2002 +bourbon +brennan +bumble +books +hose +heyyou +houston1 +hemlock +hippo +hornets +hurricane +horseman +hogan +excess +extensa +muffin1 +virginie +werdna +idontknow +info +iron +jack1 +1bitch +151nxjmt +bendover +bmwbmw +bills +zaq123 +wxcvbn +surprise +supernov +tahoe +talbot +simona +shakur +sexyone +seviyi +sonja +smart1 +speed1 +pepito +phantom1 +playoffs +terry1 +terrier +laser1 +lite +lancia +johngalt +jenjen +jolene +midori +message +maserati +matteo +mental +miami1 +riffraff +ronald1 +reason +rhythm +1218 +1026 +123987 +1015 +1103 +armada +architec +austria +gotmilk +hawkins +gray +camila +camp +cambridg +charge +camero +flex +foreplay +getoff +glacier +glotest +froggie +gerbil +rugger +sanity72 +salesman +donna1 +dreaming +deutsch +orchard +oyster +palmtree +ophelia +pajero +m5wkqf +magenta +luckyone +treefrog +vantage +usmarine +tyvugq +uptown +abacab +aaaaaa1 +advance +chuck1 +delmar +darkange +cyclones +nate +navajo +nope +border +bubba123 +building +iawgk2 +hrfzlz +dylan1 +enrico +encore +emilio +eclipse1 +killian +kayleigh +mutant +mizuno +mustang2 +video1 +viewer +weed420 +whales +jaguar1 +insight +1990 +159159 +1love +bliss +bears1 +bigtruck +binder +bigboss +blitz +xqgann +yeahyeah +zeke +zardoz +stickman +table +3825 +signal +sentra +side +shiva +skipper1 +singapor +southpaw +sonora +squid +slamdunk +slimjim +placid +photon +placebo +pearl1 +test12 +therock1 +tiger123 +leinad +legman +jeepers +joeblow +mccarthy +mike23 +redcar +rhinos +rjw7x4 +1102 +13576479 +112211 +alcohol +gwju3g +greywolf +7bgiqk +7878 +535353 +4snz9g +candyass +cccccc1 +carola +catfight +cali +fister +fosters +finland +frankie1 +gizzmo +fuller +royalty +rugrat +sandie +rudolf +dooley +dive +doreen +dodo +drop +oemdlg +out3xf +paddy +opennow +puppy1 +qazwsxedc +pregnant +quinn +ramjet +under +uncle +abraxas +corner +creed +cocoa +crown +cows +cn42qj +dancer1 +death666 +damned +nudity +negative +nimda2k +buick +bobb +braves1 +brook +henrik +higher +hooligan +dust +everlast +karachi +mortis +mulligan +monies +motocros +wally1 +weapon +waterman +view +willie1 +vicki +inspiron +1test +2929 +bigblack +xytfu7 +yackwin +zaq1xsw2 +yy5rbfsc +100100 +0660 +tahiti +takehana +talks +332211 +3535 +sedona +seawolf +skydiver +shine +spleen +slash +spjfet +special1 +spooner +slimshad +sopranos +spock1 +penis1 +patches1 +terri +thierry +thething +toohot +large +limpone +johnnie +mash4077 +matchbox +masterp +maxdog +ribbit +reed +rita +rockin +redhat +rising +1113 +14789632 +1331 +allday +aladin +andrey +amethyst +ariel +anytime +baseball1 +athome +basil +goofy1 +greenman +gustavo +goofball +ha8fyp +goodday +778899 +charon +chappy +castillo +caracas +cardiff +capitals +canada1 +cajun +catter +freddy1 +favorite2 +frazier +forme +follow +forsaken +feelgood +gavin +gfxqx686 +garlic +sarge +saskia +sanjose +russ +salsa +dilbert1 +dukeduke +downhill +longhair +loop +locutus +lockdown +malachi +mamacita +lolipop +rainyday +pumpkin1 +punker +prospect +rambo1 +rainbows +quake +twin +trinity1 +trooper1 +aimee +citation +coolcat +crappy +default +dental +deniro +d9ungl +daddys +napoli +nautica +nermal +bukowski +brick +bubbles1 +bogota +board +branch +breath +buds +hulk +humphrey +hitachi +evans +ender +export +kikiki +kcchiefs +kram +morticia +montrose +mongo +waqw3p +wizzard +visited +whdbtp +whkzyc +image +154ugeiu +1fuck +binky +blind +bigred1 +blubber +benz +becky1 +year2005 +wonderfu +wooden +xrated +0001 +tampabay +survey +tammy1 +stuffer +3mpz4r +3000 +3some +selina +sierra1 +shampoo +silk +shyshy +slapnuts +standby +spartan1 +sprocket +sometime +stanley1 +poker1 +plus +thought +theshit +torture +thinking +lavalamp +light1 +laserjet +jediknig +jjjjj1 +jocelyn +mazda626 +menthol +maximo +margaux +medic1 +release +richter +rhino1 +roach +renate +repair +reveal +1209 +1234321 +amigos +apricot +alexandra +asdfgh1 +hairball +hatter +graduate +grimace +7xm5rq +6789 +cartoons +capcom +cheesy +cashflow +carrots +camping +fanatic +fool +format +fleming +girlie +glover +gilmore +gardner +safeway +ruthie +dogfart +dondon +diapers +outsider +odin +opiate +lollol +love12 +loomis +mallrats +prague +primetime21 +pugsley +program +r29hqq +touch +valleywa +airman +abcdefg1 +darkone +cummer +dempsey +damn +nadia +natedogg +nineball +ndeyl5 +natchez +newone +normandy +nicetits +buddy123 +buddys +homely +husky +iceland +hr3ytm +highlife +holla +earthlin +exeter +eatmenow +kimkim +karine +k2trix +kernel +kirkland +money123 +moonman +miles1 +mufasa +mousey +wilma +wilhelm +whites +warhamme +instinct +jackass1 +2277 +20spanks +blobby +blair +blinky +bikers +blackjack +becca +blue23 +xman +wyvern +085tzzqi +zxzxzx +zsmj2v +suede +t26gn4 +sugars +sylvie +tantra +swoosh +swiss +4226 +4271 +321123 +383pdjvl +shoe +shane1 +shelby1 +spades +spain +smother +soup +sparhawk +pisser +photo1 +pebble +phones +peavey +picnic +pavement +terra +thistle +tokyo +therapy +lives +linden +kronos +lilbit +linux +johnston +material +melanie1 +marbles +redlight +reno +recall +1208 +1138 +1008 +alchemy +aolsucks +alexalex +atticus +auditt +ballet +b929ezzh +goodyear +hanna +griffith +gubber +863abgsg +7474 +797979 +464646 +543210 +4zqauf +4949 +ch5nmk +carlito +chewey +carebear +caleb +checkmat +cheddar +chachi +fever +forgetit +fine +forlife +giants1 +gates +getit +gamble +gerhard +galileo +g3ujwg +ganja +rufus1 +rushmore +scouts +discus +dudeman +olympus +oscars +osprey +madcow +locust +loyola +mammoth +proton +rabbit1 +question +ptfe3xxp +pwxd5x +purple1 +punkass +prophecy +uyxnyd +tyson1 +aircraft +access99 +abcabc +cocktail +colts +civilwar +cleveland +claudia1 +contour +clement +dddddd1 +cypher +denied +dapzu455 +dagmar +daisydog +name +noles +butters +buford +hoochie +hotel +hoser +eddy +ellis +eldiablo +kingrich +mudvayne +motown +mp8o6d +wife +vipergts +italiano +innocent +2055 +2211 +beavers +bloke +blade1 +yamato +zooropa +yqlgr667 +050505 +zxcvbnm1 +zw6syj +suckcock +tango1 +swing +stern +stephens +swampy +susanna +tammie +445566 +333666 +380zliki +sexpot +sexylady +sixtynin +sickboy +spiffy +sleeping +skylark +sparkles +slam +pintail +phreak +places +teller +timtim +tires +thighs +left +latex +llamas +letsdoit +lkjhg +landmark +letters +lizzard +marlins +marauder +metal1 +manu +register +righton +1127 +alain +alcat +amigo +basebal1 +azertyui +attract +azrael +hamper +gotenks +golfgti +gutter +hawkwind +h2slca +harman +grace1 +6chid8 +789654 +canine +casio +cazzo +chamber +cbr900 +cabrio +calypso +capetown +feline +flathead +fisherma +flipmode +fungus +goal +g9zns4 +full +giggle +gabriel1 +fuck123 +saffron +dogmeat +dreamcas +dirtydog +dunlop +douche +dresden +dickdick +destiny1 +pappy +oaktree +lydia +luft4 +puta +prayer +ramada +trumpet1 +vcradq +tulip +tracy71 +tycoon +aaaaaaa1 +conquest +click +chitown +corps +creepers +constant +couples +code +cornhole +danman +dada +density +d9ebk7 +cummins +darth +cute +nash +nirvana1 +nixon +norbert +nestle +brenda1 +bonanza +bundy +buddies +hotspur +heavy +horror +hufmqw +electro +erasure +enough +elisabet +etvww4 +ewyuza +eric1 +kinder +kenken +kismet +klaatu +musician +milamber +willi +waiting +isacs155 +igor +1million +1letmein +x35v8l +yogi +ywvxpz +xngwoj +zippy1 +020202 +**** +stonewal +sweeney +story +sentry +sexsexsex +spence +sonysony +smirnoff +star12 +solace +sledge +states +snyder +star1 +paxton +pentagon +pkxe62 +pilot1 +pommes +paulpaul +plants +tical +tictac +toes +lighthou +lemans +kubrick +letmein22 +letmesee +jys6wz +jonesy +jjjjjj1 +jigga +joelle +mate +merchant +redstorm +riley1 +rosa +relief +14141414 +1126 +allison1 +badboy1 +asthma +auggie +basement +hartley +hartford +hardwood +gumbo +616913 +57np39 +56qhxs +4mnveh +cake +forbes +fatluvr69 +fqkw5m +fidelity +feathers +fresno +godiva +gecko +gladys +gibson1 +gogators +fridge +general1 +saxman +rowing +sammys +scotts +scout1 +sasasa +samoht +dragon69 +ducky +dragonball +driller +p3wqaw +nurse +papillon +oneone +openit +optimist +longshot +portia +rapier +pussy2 +ralphie +tuxedo +ulrike +undertow +trenton +copenhag +come +delldell +culinary +deltas +mytime +nicky +nickie +noname +noles1 +bucker +bopper +bullock +burnout +bryce +hedges +ibilltes +hihje863 +hitter +ekim +espana +eatme69 +elpaso +envelope +express1 +eeeeee1 +eatme1 +karaoke +kara +mustang5 +misses +wellingt +willem +waterski +webcam +jasons +infinite +iloveyou! +jakarta +belair +bigdad +beerme +yoshi +yinyang +zimmer +x24ik3 +063dyjuy +0000007 +ztmfcq +stopit +stooges +survival +stockton +symow8 +strato +2hot4u +ship +simons +skins +shakes +sex1 +shield +snacks +softtail +slimed123 +pizzaman +pipe +pitt +pathetic +pinto +tigercat +tonton +lager +lizzy +juju +john123 +jennings +josiah +jesse1 +jordon +jingles +martian +mario1 +rootedit +rochard +redwine +requiem +riverrat +rats +1117 +1014 +1205 +althea +allie +amor +amiga +alpina +alert +atreides +banana1 +bahamut +hart +golfman +happines +7uftyx +5432 +5353 +5151 +4747 +byron +chatham +chadwick +cherie +foxfire +ffvdj474 +freaked +foreskin +gayboy +gggggg1 +glenda +gameover +glitter +funny1 +scoobydoo +scroll +rudolph +saddle +saxophon +dingbat +digimon +omicron +parsons +ohio +panda1 +loloxx +macintos +lululu +lollypop +racer1 +queen1 +qwertzui +prick +upnfmc +tyrant +trout1 +9skw5g +aceman +adelaide +acls2h +aaabbb +acapulco +aggie +comcast +craft +crissy +cloudy +cq2kph +custer +d6o8pm +cybersex +davecole +darian +crumbs +daisey +davedave +dasani +needle +mzepab +myporn +narnia +nineteen +booger1 +bravo1 +budgie +btnjey +highlander +hotel6 +humbug +edwin +ewtosi +kristin1 +kobe +knuckles +keith1 +katarina +muff +muschi +montana1 +wingchun +wiggle +whatthe +walking +watching +vette1 +vols +virago +intj3a +ishmael +intern +jachin +illmatic +199999 +2010 +beck +blender +bigpenis +bengal +blue1234 +your +zaqxsw +xray +xxxxxxx1 +zebras +yanks +worlds +tadpole +stripes +svetlana +3737 +4343 +3728 +4444444 +368ejhih +solar +sonne +smalls +sniffer +sonata +squirts +pitcher +playstation +pktmxr +pescator +points +texaco +lesbos +lilian +l8v53x +jo9k2jw2 +jimbeam +josie +jimi +jupiter2 +jurassic +marines1 +maya +rocket1 +ringer +14725836 +12345679 +1219 +123098 +1233 +alessand +althor +angelika +arch +armando +alpha123 +basher +barefeet +balboa +bbbbb1 +banks +badabing +harriet +gopack +golfnut +gsxr1000 +gregory1 +766rglqy +8520 +753159 +8dihc6 +69camaro +666777 +cheeba +chino +calendar +cheeky +camel1 +fishcake +falling +flubber +giuseppe +gianni +gloves +gnasher23 +frisbee +fuzzy1 +fuzzball +sauce +save13tx +schatz +russell1 +sandra1 +scrotum +scumbag +sabre +samdog +dripping +dragon12 +dragster +paige +orwell +mainland +lunatic +lonnie +lotion +maine +maddux +qn632o +poophead +rapper +porn4life +producer +rapunzel +tracks +velocity +vanessa1 +ulrich +trueblue +vampire1 +abacus +902100 +crispy +corky +crane +chooch +d6wnro +cutie +deal +dabulls +dehpye +navyseal +njqcw4 +nownow +nigger1 +nightowl +nonenone +nightmar +bustle +buddy2 +boingo +bugman +bulletin +bosshog +bowie +hybrid +hillside +hilltop +hotlegs +honesty +hzze929b +hhhhh1 +hellohel +eloise +evilone +edgewise +e5pftu +eded +embalmer +excalibur +elefant +kenzie +karl +karin +killah +kleenex +mouses +mounta1n +motors +mutley +muffdive +vivitron +winfield +wednesday +w00t88 +iloveit +jarjar +incest +indycar +17171717 +1664 +17011701 +222777 +2663 +beelch +benben +yitbos +yyyyy1 +yasmin +zapata +zzzzz1 +stooge +tangerin +taztaz +stewart1 +summer69 +sweetness +system1 +surveyor +stirling +3qvqod +3way +456321 +sizzle +simhrq +shrink +shawnee +someday +sparty +ssptx452 +sphere +spark +slammed +sober +persian +peppers +ploppy +pn5jvw +poobear +pianos +plaster +testme +tiff +thriller +larissa +lennox +jewell +master12 +messier +rockey +1229 +1217 +1478 +1009 +anastasi +almighty +amonra +aragon +argentin +albino +azazel +grinder +6uldv8 +83y6pv +8888888 +4tlved +515051 +carsten +changes +flanders +flyers88 +ffffff1 +firehawk +foreman +firedog +flashman +ggggg1 +gerber +godspeed +galway +giveitup +funtimes +gohan +giveme +geryfe +frenchie +sayang +rudeboy +savanna +sandals +devine +dougal +drag0n +dga9la +disaster +desktop +only +onlyone +otter +pandas +mafia +lombard +luckys +lovejoy +lovelife +manders +product +qqh92r +qcmfd454 +pork +radar1 +punani +ptbdhw +turtles +undertaker +trs8f7 +tramp +ugejvp +abba +911turbo +acdc +abcd123 +clever +corina +cristian +create +crash1 +colony +crosby +delboy +daniele +davinci +daughter +notebook +niki +nitrox +borabora +bonzai +budd +brisbane +hotter +heeled +heroes +hooyah +hotgirl +i62gbq +horse1 +hills +hpk2qc +epvjb6 +echo +korean +kristie +mnbvc +mohammad +mind +mommy1 +munster +wade +wiccan +wanted +jacket +2369 +bettyboo +blondy +bismark +beanbag +bjhgfi +blackice +yvtte545 +ynot +yess +zlzfrh +wolvie +007bond +****** +tailgate +tanya1 +sxhq65 +stinky1 +3234412 +3ki42x +seville +shimmer +sheryl +sienna +shitshit +skillet +seaman +sooners1 +solaris +smartass +pastor +pasta +pedros +pennywis +pfloyd +tobydog +thetruth +lethal +letme1n +leland +jenifer +mario66 +micky +rocky2 +rewq +ripped +reindeer +1128 +1207 +1104 +1432 +aprilia +allstate +alyson +bagels +basic +baggies +barb +barrage +greatest +gomez +guru +guard +72d5tn +606060 +4wcqjn +caldwell +chance1 +catalog +faust +film +flange +fran +fartman +geil +gbhcf2 +fussball +glen +fuaqz4 +gameboy +garnet +geneviev +rotary +seahawk +russel +saab +seal +samadams +devlt4 +ditto +drevil +drinker +deuce +dipstick +donut +octopus +ottawa +losangel +loverman +porky +q9umoz +rapture +pump +pussy4me +university +triplex +ue8fpw +trent +trophy +turbos +troubles +agent +aaa340 +churchil +crazyman +consult +creepy +craven +class +cutiepie +ddddd1 +dejavu +cuxldv +nettie +nbvibt +nikon +niko +norwood +nascar1 +nolan +bubba2 +boobear +boogers +buff +bullwink +bully +bulldawg +horsemen +escalade +editor +eagle2 +dynamic +ella +efyreg +edition +kidney +minnesot +mogwai +morrow +msnxbi +moonlight +mwq6qlzo +wars +werder +verygood +voodoo1 +wheel +iiiiii1 +159951 +1624 +1911a1 +2244 +bellagio +bedlam +belkin +bill1 +woodrow +xirt2k +worship +?????? +tanaka +swift +susieq +sundown +sukebe +tales +swifty +2fast4u +senate +sexe +sickness +shroom +shaun +seaweed +skeeter1 +status +snicker +sorrow +spanky1 +spook +patti +phaedrus +pilots +pinch +peddler +theo +thumper1 +tessie +tiger7 +tmjxn151 +thematri +l2g7k3 +letmeinn +lazy +jeffjeff +joan +johnmish +mantra +mariana +mike69 +marshal +mart +mazda6 +riptide +robots +rental +1107 +1130 +142857 +11001001 +1134 +armored +alvin +alec +allnight +alright +amatuers +bartok +attorney +astral +baboon +bahamas +balls1 +bassoon +hcleeb +happyman +granite +graywolf +golf1 +gomets +8vjzus +7890 +789123 +8uiazp +5757 +474jdvff +551scasi +50cent +camaro1 +cherry1 +chemist +final +firenze +fishtank +farrell +freewill +glendale +frogfrog +gerhardt +ganesh +same +scirocco +devilman +doodles +dinger +okinawa +olympic +nursing +orpheus +ohmygod +paisley +pallmall +null +lounge +lunchbox +manhatta +mahalo +mandarin +qwqwqw +qguvyt +pxx3eftp +president +rambler +puzzle +poppy1 +turk182 +trotter +vdlxuc +trish +tugboat +valiant +tracie +uwrl7c +chris123 +coaster +cmfnpu +decimal +debbie1 +dandy +daedalus +dede +natasha1 +nissan1 +nancy123 +nevermin +napalm +newcastle +boats +branden +britt +bonghit +hester +ibxnsm +hhhhhh1 +holger +durham +edmonton +erwin +equinox +dvader +kimmy +knulla +mustafa +monsoon +mistral +morgana +monica1 +mojave +month +monterey +mrbill +vkaxcs +victor1 +wacker +wendell +violator +vfdhif +wilson1 +wavpzt +verena +wildstar +winter99 +iqzzt580 +jarrod +imback +1914 +19741974 +1monkey +1q2w3e4r5t +2500 +2255 +blank +bigshow +bigbucks +blackcoc +zoomer +wtcacq +wobble +xmen +xjznq5 +yesterda +yhwnqc +zzzxxx +streak +393939 +2fchbg +skinhead +skilled +shakira +shaft +shadow12 +seaside +sigrid +sinful +silicon +smk7366 +snapshot +sniper1 +soccer11 +staff +slap +smutty +peepers +pleasant +plokij +pdiddy +pimpdaddy +thrust +terran +topaz +today1 +lionhear +littlema +lauren1 +lincoln1 +lgnu9d +laughing +juneau +methos +medina +merlyn +rogue1 +romulus +redshift +1202 +1469 +12locked +arizona1 +alfarome +al9agd +aol123 +altec +apollo1 +arse +baker1 +bbb747 +bach +axeman +astro1 +hawthorn +goodfell +hawks1 +gstring +hannes +8543852 +868686 +4ng62t +554uzpad +5401 +567890 +5232 +catfood +frame +flow +fire1 +flipflop +fffff1 +fozzie +fluff +garrison +fzappa +furious +round +rustydog +sandberg +scarab +satin +ruger +samsung1 +destin +diablo2 +dreamer1 +detectiv +dominick +doqvq3 +drywall +paladin1 +papabear +offroad +panasonic +nyyankee +luetdi +qcfmtz +pyf8ah +puddles +privacy +rainer +pussyeat +ralph1 +princeto +trivia +trewq +tri5a3 +advent +9898 +agyvorc +clarkie +coach1 +courier +contest +christo +corinna +chowder +concept +climbing +cyzkhw +davidb +dad2ownu +days +daredevi +de7mdf +nose +necklace +nazgul +booboo1 +broad +bonzo +brenna +boot +butch1 +huskers1 +hgfdsa +hornyman +elmer +elektra +england1 +elodie +kermit1 +knife +kaboom +minute +modern +motherfucker +morten +mocha +monday1 +morgoth +ward +weewee +weenie +walters +vorlon +website +wahoo +ilovegod +insider +jayman +1911 +1dallas +1900 +1ranger +201jedlz +2501 +1qaz +bertram +bignuts +bigbad +beebee +billows +belize +bebe +wvj5np +wu4etd +yamaha1 +wrinkle5 +zebra1 +yankee1 +zoomzoom +09876543 +0311 +????? +stjabn +tainted +3tmnej +shoot +skooter +skelter +sixteen +starlite +smack +spice1 +stacey1 +smithy +perrin +pollux +peternorth +pixie +paulina +piston +pick +poets +pine +toons +tooth +topspin +kugm7b +legends +jeepjeep +juliana +joystick +junkmail +jojojojo +jonboy +judge +midland +meteor +mccabe +matter +mayfair +meeting +merrill +raul +riches +reznor +rockrock +reboot +reject +robyn +renee1 +roadway +rasta220 +1411 +1478963 +1019 +archery +allman +andyandy +barks +bagpuss +auckland +gooseman +hazmat +gucci +guns +grammy +happydog +greek +7kbe9d +7676 +6bjvpe +5lyedn +5858 +5291 +charlie2 +chas +c7lrwu +candys +chateau +ccccc1 +cardinals +fear +fihdfv +fortune12 +gocats +gaelic +fwsadn +godboy +gldmeo +fx3tuo +fubar1 +garland +generals +gforce +rxmtkp +rulz +sairam +dunhill +division +dogggg +detect +details +doll +drinks +ozlq6qwm +ov3ajy +lockout +makayla +macgyver +mallorca +loves +prima +pvjegu +qhxbij +raphael +prelude1 +totoro +tusymo +trousers +tunnel +valeria +tulane +turtle1 +tracy1 +aerosmit +abbey1 +address +clticic +clueless +cooper1 +comets +collect +corbin +delpiero +derick +cyprus +dante1 +dave1 +nounours +neal +nexus6 +nero +nogard +norfolk +brent1 +booyah +bootleg +buckaroo +bulls23 +bulls1 +booper +heretic +icecube +hellno +hounds +honeydew +hooters1 +hoes +howie +hevnm4 +hugohugo +eighty +epson +evangeli +eeeee1 +eyphed diff --git a/apps/api/src/features/auth/repo.ts b/apps/api/src/features/auth/repo.ts new file mode 100644 index 0000000..4fc706a --- /dev/null +++ b/apps/api/src/features/auth/repo.ts @@ -0,0 +1,31 @@ +import type { PoolClient } from "pg"; + +export async function insertUserReturningId( + client: PoolClient, + email: string, + passwordHash: string +): Promise { + const result = await client.query<{ id: string }>( + `INSERT INTO users (email, password_hash) VALUES ($1, $2) + ON CONFLICT (lower(email)) DO NOTHING + RETURNING id`, + [email, passwordHash] + ); + return result.rows[0]?.id ?? null; +} + +export async function findUserIdByEmail(client: PoolClient, email: string): Promise { + const result = await client.query<{ id: string }>( + "SELECT id FROM users WHERE lower(email) = lower($1)", + [email] + ); + return result.rows[0]?.id ?? null; +} + +export async function insertEmailOutbox( + client: PoolClient, + userId: string, + kind: "verify_email" | "account_exists" | "password_reset" +): Promise { + await client.query("INSERT INTO email_outbox (user_id, kind) VALUES ($1, $2)", [userId, kind]); +} diff --git a/apps/api/src/features/auth/routes.ts b/apps/api/src/features/auth/routes.ts new file mode 100644 index 0000000..0be3c87 --- /dev/null +++ b/apps/api/src/features/auth/routes.ts @@ -0,0 +1,21 @@ +import type { FastifyInstance } from "fastify"; + +import { getPool } from "../../db.js"; +import { signup, ValidationError, validateSignupInput } from "./service.js"; + +export async function authRoutes(app: FastifyInstance): Promise { + app.post("/signup", async (request, reply) => { + const body = request.body as { email?: unknown; password?: unknown } | undefined; + try { + const { email, password } = validateSignupInput(body?.email, body?.password); + await signup(getPool(), email, password); + } catch (err) { + if (err instanceof ValidationError) { + reply.code(400).send({ error: err.code }); + return; + } + throw err; + } + reply.code(202).send({ message: "check your email" }); + }); +} diff --git a/apps/api/src/features/auth/service.ts b/apps/api/src/features/auth/service.ts new file mode 100644 index 0000000..9baf0c7 --- /dev/null +++ b/apps/api/src/features/auth/service.ts @@ -0,0 +1,54 @@ +import argon2 from "argon2"; +import type { Pool } from "pg"; + +import { isCommonPassword } from "./common-passwords.js"; +import { findUserIdByEmail, insertEmailOutbox, insertUserReturningId } from "./repo.js"; + +const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +export class ValidationError extends Error { + constructor(public code: string) { + super(code); + } +} + +// argon2id, m = 19 MiB, t = 2, p = 1 (§9). +const ARGON2_OPTIONS = { type: argon2.argon2id, memoryCost: 19 * 1024, timeCost: 2, parallelism: 1 }; + +export function validateSignupInput(email: unknown, password: unknown): { email: string; password: string } { + if (typeof email !== "string" || !EMAIL_RE.test(email)) { + throw new ValidationError("invalid_email"); + } + if (typeof password !== "string" || password.length < 10 || password.length > 128) { + throw new ValidationError("invalid_password"); + } + if (isCommonPassword(password)) { + throw new ValidationError("common_password"); + } + return { email, password }; +} + +// Always succeeds from the caller's point of view (§9: signup never reveals whether an +// email exists) — the 202 response is the same regardless of which branch runs below. +export async function signup(pool: Pool, email: string, password: string): Promise { + const passwordHash = await argon2.hash(password, ARGON2_OPTIONS); + const client = await pool.connect(); + try { + await client.query("BEGIN"); + const newUserId = await insertUserReturningId(client, email, passwordHash); + if (newUserId !== null) { + await insertEmailOutbox(client, newUserId, "verify_email"); + } else { + const existingUserId = await findUserIdByEmail(client, email); + // existingUserId is always found here: the ON CONFLICT above only fires when a row + // with this lower(email) already exists. + await insertEmailOutbox(client, existingUserId as string, "account_exists"); + } + await client.query("COMMIT"); + } catch (err) { + await client.query("ROLLBACK"); + throw err; + } finally { + client.release(); + } +} diff --git a/apps/api/test/signup.test.ts b/apps/api/test/signup.test.ts new file mode 100644 index 0000000..ca3d541 --- /dev/null +++ b/apps/api/test/signup.test.ts @@ -0,0 +1,87 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { buildApp, WEB_ORIGIN } from "../src/app.js"; +import { getPool } from "../src/db.js"; + +// *.example is RFC 2606 reserved; mirrors apps/ingestion's test-fixture convention. +const EMAIL = "signup-test@mail.example"; + +async function cleanup(): Promise { + await getPool().query( + "DELETE FROM email_outbox WHERE user_id IN (SELECT id FROM users WHERE lower(email) = lower($1))", + [EMAIL] + ); + await getPool().query("DELETE FROM users WHERE lower(email) = lower($1)", [EMAIL]); +} + +beforeEach(cleanup); +afterEach(cleanup); + +function post(app: ReturnType, body: unknown) { + return app.inject({ + method: "POST", + url: "/auth/signup", + headers: { origin: WEB_ORIGIN, "content-type": "application/json" }, + payload: body as object, + }); +} + +describe("POST /auth/signup", () => { + it("creates a user and queues verify_email for a new address", async () => { + const app = buildApp(); + const res = await post(app, { email: EMAIL, password: "a-strong-passphrase" }); + expect(res.statusCode).toBe(202); + + const users = await getPool().query("SELECT id, email_verified_at FROM users WHERE lower(email) = lower($1)", [EMAIL]); + expect(users.rowCount).toBe(1); + expect(users.rows[0].email_verified_at).toBeNull(); + + const outbox = await getPool().query( + "SELECT kind, status FROM email_outbox WHERE user_id = $1", + [users.rows[0].id] + ); + expect(outbox.rows).toEqual([{ kind: "verify_email", status: "pending" }]); + await app.close(); + }); + + it("does not duplicate the user and queues account_exists on a repeat signup", async () => { + const app = buildApp(); + await post(app, { email: EMAIL, password: "a-strong-passphrase" }); + const res = await post(app, { email: EMAIL, password: "a-different-passphrase" }); + expect(res.statusCode).toBe(202); + + const users = await getPool().query("SELECT id FROM users WHERE lower(email) = lower($1)", [EMAIL]); + expect(users.rowCount).toBe(1); + + const outbox = await getPool().query( + "SELECT kind FROM email_outbox WHERE user_id = $1 ORDER BY created_at", + [users.rows[0].id] + ); + expect(outbox.rows.map((r) => r.kind)).toEqual(["verify_email", "account_exists"]); + await app.close(); + }); + + it("rejects a malformed email with 400 invalid_email", async () => { + const app = buildApp(); + const res = await post(app, { email: "not-an-email", password: "a-strong-passphrase" }); + expect(res.statusCode).toBe(400); + expect(res.json()).toEqual({ error: "invalid_email" }); + await app.close(); + }); + + it("rejects a too-short password with 400 invalid_password", async () => { + const app = buildApp(); + const res = await post(app, { email: EMAIL, password: "short" }); + expect(res.statusCode).toBe(400); + expect(res.json()).toEqual({ error: "invalid_password" }); + await app.close(); + }); + + it("rejects a top-10k common password with 400 common_password", async () => { + const app = buildApp(); + const res = await post(app, { email: EMAIL, password: "qwertyuiop" }); + expect(res.statusCode).toBe(400); + expect(res.json()).toEqual({ error: "common_password" }); + await app.close(); + }); +}); From 797ad7688d5716f557723d70ee75d0fb857d9064 Mon Sep 17 00:00:00 2001 From: saran Date: Sat, 10 Oct 2026 18:12:04 +0530 Subject: [PATCH 04/12] auth related workflows and plugins built including refresh tokens and JWT verify --- apps/api/package-lock.json | 146 ++++++++++++++++++ apps/api/package.json | 2 + apps/api/src/app.ts | 12 +- apps/api/src/cookie.ts | 21 +++ apps/api/src/db.ts | 23 ++- apps/api/src/features/auth/jwt.ts | 44 ++++++ apps/api/src/features/auth/repo.ts | 142 ++++++++++++++++++ apps/api/src/features/auth/routes.ts | 117 ++++++++++++++- apps/api/src/features/auth/service.ts | 206 ++++++++++++++++++++++++-- apps/api/src/features/auth/tokens.ts | 11 ++ apps/api/src/plugins/auth.ts | 53 ++++++- apps/api/test/auth-plugin.test.ts | 64 ++++++++ apps/api/test/helpers.ts | 46 ++++++ apps/api/test/login.test.ts | 51 +++++++ apps/api/test/refresh.test.ts | 72 +++++++++ apps/api/test/reset.test.ts | 79 ++++++++++ apps/api/test/verify.test.ts | 57 +++++++ 17 files changed, 1119 insertions(+), 27 deletions(-) create mode 100644 apps/api/src/cookie.ts create mode 100644 apps/api/src/features/auth/jwt.ts create mode 100644 apps/api/src/features/auth/tokens.ts create mode 100644 apps/api/test/auth-plugin.test.ts create mode 100644 apps/api/test/helpers.ts create mode 100644 apps/api/test/login.test.ts create mode 100644 apps/api/test/refresh.test.ts create mode 100644 apps/api/test/reset.test.ts create mode 100644 apps/api/test/verify.test.ts diff --git a/apps/api/package-lock.json b/apps/api/package-lock.json index fefe265..b0590b5 100644 --- a/apps/api/package-lock.json +++ b/apps/api/package-lock.json @@ -10,9 +10,11 @@ "dependencies": { "argon2": "^0.41.1", "fastify": "^5.1.0", + "jsonwebtoken": "^9.0.2", "pg": "^8.13.1" }, "devDependencies": { + "@types/jsonwebtoken": "^9.0.7", "@types/node": "^22.10.1", "@types/pg": "^8.11.10", "tsx": "^4.19.2", @@ -946,6 +948,24 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/jsonwebtoken": { + "version": "9.0.10", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz", + "integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/ms": "*", + "@types/node": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "22.20.5", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.5.tgz", @@ -1115,6 +1135,12 @@ "fastq": "^1.17.1" } }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, "node_modules/chai": { "version": "6.3.0", "resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz", @@ -1158,6 +1184,15 @@ "node": ">=8" } }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/es-module-lexer": { "version": "2.3.2", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", @@ -1411,6 +1446,49 @@ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "license": "MIT" }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, "node_modules/light-my-request": { "version": "6.8.0", "resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-6.8.0.tgz", @@ -1706,6 +1784,48 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, "node_modules/magic-string": { "version": "1.4.3", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.3.tgz", @@ -1716,6 +1836,12 @@ "@jridgewell/sourcemap-codec": "^1.6.0" } }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, "node_modules/nanoid": { "version": "3.3.20", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", @@ -2092,6 +2218,26 @@ "@rolldown/binding-win32-x64-msvc": "1.2.13" } }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/safe-regex2": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.1.2.tgz", diff --git a/apps/api/package.json b/apps/api/package.json index 1795ddc..805216a 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -12,9 +12,11 @@ "dependencies": { "argon2": "^0.41.1", "fastify": "^5.1.0", + "jsonwebtoken": "^9.0.2", "pg": "^8.13.1" }, "devDependencies": { + "@types/jsonwebtoken": "^9.0.7", "@types/node": "^22.10.1", "@types/pg": "^8.11.10", "tsx": "^4.19.2", diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index c139e38..a80be27 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -43,13 +43,19 @@ export function buildApp(): FastifyInstance { app.register(authRoutes, { prefix: "/auth" }); // /api/public/* — cache plugin, no auth plugin: handlers here cannot read user identity. + // The plugin is called directly (not via .register()) so its hook lands on this same + // scope instead of an isolated child context — Fastify's encapsulation would otherwise + // keep it from applying to sibling routes registered directly on publicScope. app.register(async (publicScope) => { - await publicScope.register(cachePlugin); + await cachePlugin(publicScope); }, { prefix: "/api/public" }); - // /api/* — auth plugin, no cache plugin. + // /api/* — auth plugin, no cache plugin. Same direct-call reasoning as above. app.register(async (apiScope) => { - await apiScope.register(authPlugin); + await authPlugin(apiScope); + // Minimal authenticated echo route — first real consumer of request.userId, and gives + // the auth plugin an actual registered route to run its hook against. + apiScope.get("/whoami", async (request) => ({ userId: request.userId })); }, { prefix: "/api" }); return app; diff --git a/apps/api/src/cookie.ts b/apps/api/src/cookie.ts new file mode 100644 index 0000000..0c21618 --- /dev/null +++ b/apps/api/src/cookie.ts @@ -0,0 +1,21 @@ +// Minimal Cookie header read + Set-Cookie build — only one cookie (the refresh token) is +// in play, so a small dependency-free helper beats pulling in a cookie library. +export function readCookie(header: string | undefined, name: string): string | undefined { + if (!header) return undefined; + for (const part of header.split(";")) { + const idx = part.indexOf("="); + if (idx === -1) continue; + if (part.slice(0, idx).trim() === name) return decodeURIComponent(part.slice(idx + 1).trim()); + } + return undefined; +} + +export function buildSetCookie( + name: string, + value: string, + opts: { path: string; maxAgeSeconds?: number } +): string { + const parts = [`${name}=${encodeURIComponent(value)}`, `Path=${opts.path}`, "HttpOnly", "Secure", "SameSite=Strict"]; + if (opts.maxAgeSeconds !== undefined) parts.push(`Max-Age=${opts.maxAgeSeconds}`); + return parts.join("; "); +} diff --git a/apps/api/src/db.ts b/apps/api/src/db.ts index b448522..4be164a 100644 --- a/apps/api/src/db.ts +++ b/apps/api/src/db.ts @@ -1,5 +1,11 @@ // Postgres access: one pooled client, shared across requests (ARCHITECTURE §4). -import { Pool } from "pg"; +import { Pool, type PoolClient, types } from "pg"; + +// node-pg returns BIGINT (OID 20) as a string by default to avoid silent precision loss. +// None of our bigints (auth_version, the *_runs/*_events identity columns) approach +// Number.MAX_SAFE_INTEGER, and treating auth_version as a string would corrupt the JWT +// `av` claim's JSON type, so parse it to a number here instead of at every call site. +types.setTypeParser(20, (val: string) => parseInt(val, 10)); export const DATABASE_URL = process.env.DATABASE_URL ?? "postgresql://newstack:newstack@localhost:5432/newstack"; @@ -12,3 +18,18 @@ export function getPool(): Pool { } return pool; } + +export async function withTransaction(pool: Pool, fn: (client: PoolClient) => Promise): Promise { + const client = await pool.connect(); + try { + await client.query("BEGIN"); + const result = await fn(client); + await client.query("COMMIT"); + return result; + } catch (err) { + await client.query("ROLLBACK"); + throw err; + } finally { + client.release(); + } +} diff --git a/apps/api/src/features/auth/jwt.ts b/apps/api/src/features/auth/jwt.ts new file mode 100644 index 0000000..e52c2e4 --- /dev/null +++ b/apps/api/src/features/auth/jwt.ts @@ -0,0 +1,44 @@ +// Access tokens (§9): 15 min JWT, claims `sub` + `av`, header `kid`. Two keys active +// (current + previous) so verification survives a quarterly rotation; signing always uses +// the current one. +import jwt from "jsonwebtoken"; + +const CURRENT_KID = process.env.JWT_CURRENT_KID ?? "dev1"; +const KEYS: Record = JSON.parse( + process.env.JWT_KEYS ?? '{"dev1":"dev-insecure-secret-change-me"}' +); + +export class AccessTokenError extends Error {} + +export interface AccessTokenClaims { + sub: string; + av: number; +} + +export function signAccessToken(userId: string, authVersion: number): string { + const secret = KEYS[CURRENT_KID]; + if (secret === undefined) throw new Error(`no signing key configured for kid ${CURRENT_KID}`); + return jwt.sign({ av: authVersion }, secret, { + subject: userId, + keyid: CURRENT_KID, + expiresIn: "15m", + }); +} + +export function verifyAccessToken(token: string): AccessTokenClaims { + const decoded = jwt.decode(token, { complete: true }); + const kid = decoded?.header.kid; + if (typeof kid !== "string" || KEYS[kid] === undefined) { + throw new AccessTokenError("unknown_kid"); + } + try { + const payload = jwt.verify(token, KEYS[kid]) as jwt.JwtPayload; + if (typeof payload.sub !== "string" || typeof payload.av !== "number") { + throw new AccessTokenError("malformed_claims"); + } + return { sub: payload.sub, av: payload.av }; + } catch (err) { + if (err instanceof AccessTokenError) throw err; + throw new AccessTokenError("invalid_token"); + } +} diff --git a/apps/api/src/features/auth/repo.ts b/apps/api/src/features/auth/repo.ts index 4fc706a..0c9747f 100644 --- a/apps/api/src/features/auth/repo.ts +++ b/apps/api/src/features/auth/repo.ts @@ -29,3 +29,145 @@ export async function insertEmailOutbox( ): Promise { await client.query("INSERT INTO email_outbox (user_id, kind) VALUES ($1, $2)", [userId, kind]); } + +export async function insertAuthEvent( + client: PoolClient, + userId: string | null, + event: string, + opts: { familyId?: string; reason?: string } = {} +): Promise { + await client.query( + "INSERT INTO auth_events (user_id, family_id, event, reason) VALUES ($1, $2, $3, $4)", + [userId, opts.familyId ?? null, event, opts.reason ?? null] + ); +} + +// ───────────────────────── Email verification (Task C) ───────────────────────── + +export async function consumeEmailVerificationToken( + client: PoolClient, + tokenHash: string +): Promise { + const result = await client.query<{ user_id: string }>( + `UPDATE email_verification_tokens SET used_at = now() + WHERE token_hash = $1 AND used_at IS NULL AND expires_at > now() + RETURNING user_id`, + [tokenHash] + ); + return result.rows[0]?.user_id ?? null; +} + +export async function markEmailVerified(client: PoolClient, userId: string): Promise { + await client.query("UPDATE users SET email_verified_at = now() WHERE id = $1", [userId]); +} + +// ───────────────────────── Login (Task D) ───────────────────────── + +export interface LoginUserRow { + id: string; + password_hash: string; + auth_version: number; +} + +export async function findUserForLogin(client: PoolClient, email: string): Promise { + const result = await client.query( + "SELECT id, password_hash, auth_version FROM users WHERE lower(email) = lower($1)", + [email] + ); + return result.rows[0] ?? null; +} + +export async function insertRefreshSession( + client: PoolClient, + args: { userId: string; familyId: string; parentId: string | null; tokenHash: string; expiresAt: Date } +): Promise { + await client.query( + `INSERT INTO refresh_sessions (user_id, family_id, parent_id, token_hash, expires_at) + VALUES ($1, $2, $3, $4, $5)`, + [args.userId, args.familyId, args.parentId, args.tokenHash, args.expiresAt] + ); +} + +export async function getAuthVersion(client: PoolClient, userId: string): Promise { + const result = await client.query<{ auth_version: number }>( + "SELECT auth_version FROM users WHERE id = $1", + [userId] + ); + return result.rows[0]?.auth_version ?? null; +} + +// ───────────────────────── Refresh rotation (Task F) ───────────────────────── + +export interface RefreshSessionRow { + id: string; + user_id: string; + family_id: string; + rotated_at: Date | null; + children: number; +} + +export async function selectRefreshSessionForUpdate( + client: PoolClient, + tokenHash: string +): Promise { + const result = await client.query( + `SELECT r.id, r.user_id, r.family_id, r.rotated_at, + (SELECT count(*) FROM refresh_sessions c WHERE c.parent_id = r.id)::int AS children + FROM refresh_sessions r + WHERE r.token_hash = $1 AND r.revoked_at IS NULL AND r.expires_at > now() + FOR UPDATE`, + [tokenHash] + ); + return result.rows[0] ?? null; +} + +export async function markRefreshRotated(client: PoolClient, id: string): Promise { + await client.query("UPDATE refresh_sessions SET rotated_at = now() WHERE id = $1", [id]); +} + +export async function revokeRefreshFamily(client: PoolClient, familyId: string): Promise { + await client.query( + "UPDATE refresh_sessions SET revoked_at = now() WHERE family_id = $1 AND revoked_at IS NULL", + [familyId] + ); +} + +// ───────────────────────── Password reset (Task G) ───────────────────────── + +export async function consumePasswordResetToken( + client: PoolClient, + tokenHash: string +): Promise { + const result = await client.query<{ user_id: string }>( + `UPDATE password_reset_tokens SET used_at = now() + WHERE token_hash = $1 AND used_at IS NULL AND expires_at > now() + RETURNING user_id`, + [tokenHash] + ); + return result.rows[0]?.user_id ?? null; +} + +export async function updatePasswordAndBumpAuthVersion( + client: PoolClient, + userId: string, + newPasswordHash: string +): Promise { + await client.query( + "UPDATE users SET password_hash = $1, auth_version = auth_version + 1 WHERE id = $2", + [newPasswordHash, userId] + ); +} + +export async function invalidateOtherResetTokens(client: PoolClient, userId: string): Promise { + await client.query( + "UPDATE password_reset_tokens SET used_at = now() WHERE user_id = $1 AND used_at IS NULL", + [userId] + ); +} + +export async function revokeAllRefreshSessions(client: PoolClient, userId: string): Promise { + await client.query( + "UPDATE refresh_sessions SET revoked_at = now() WHERE user_id = $1 AND revoked_at IS NULL", + [userId] + ); +} diff --git a/apps/api/src/features/auth/routes.ts b/apps/api/src/features/auth/routes.ts index 0be3c87..095bcdf 100644 --- a/apps/api/src/features/auth/routes.ts +++ b/apps/api/src/features/auth/routes.ts @@ -1,7 +1,34 @@ -import type { FastifyInstance } from "fastify"; +import type { FastifyInstance, FastifyReply } from "fastify"; +import { buildSetCookie, readCookie } from "../../cookie.js"; import { getPool } from "../../db.js"; -import { signup, ValidationError, validateSignupInput } from "./service.js"; +import { + AuthError, + confirmPasswordReset, + login, + refresh as refreshSession, + requestPasswordReset, + signup, + validateNewPassword, + validateSignupInput, + ValidationError, + verifyEmail, +} from "./service.js"; + +const REFRESH_COOKIE = "refresh_token"; +const REFRESH_COOKIE_PATH = "/auth/refresh"; +const REFRESH_MAX_AGE_SECONDS = 30 * 24 * 60 * 60; + +function setRefreshCookie(reply: FastifyReply, token: string): void { + reply.header( + "set-cookie", + buildSetCookie(REFRESH_COOKIE, token, { path: REFRESH_COOKIE_PATH, maxAgeSeconds: REFRESH_MAX_AGE_SECONDS }) + ); +} + +function clearRefreshCookie(reply: FastifyReply): void { + reply.header("set-cookie", buildSetCookie(REFRESH_COOKIE, "", { path: REFRESH_COOKIE_PATH, maxAgeSeconds: 0 })); +} export async function authRoutes(app: FastifyInstance): Promise { app.post("/signup", async (request, reply) => { @@ -18,4 +45,90 @@ export async function authRoutes(app: FastifyInstance): Promise { } reply.code(202).send({ message: "check your email" }); }); + + app.post("/verify", async (request, reply) => { + const body = request.body as { token?: unknown } | undefined; + if (typeof body?.token !== "string") { + reply.code(400).send({ error: "invalid_or_used" }); + return; + } + try { + await verifyEmail(getPool(), body.token); + } catch (err) { + if (err instanceof ValidationError) { + reply.code(400).send({ error: err.code }); + return; + } + throw err; + } + reply.code(200).send({ status: "verified" }); + }); + + app.post("/login", async (request, reply) => { + const body = request.body as { email?: unknown; password?: unknown } | undefined; + if (typeof body?.email !== "string" || typeof body?.password !== "string") { + reply.code(401).send({ error: "invalid_credentials" }); + return; + } + try { + const result = await login(getPool(), body.email, body.password); + setRefreshCookie(reply, result.refreshToken); + reply.code(200).send({ accessToken: result.accessToken }); + } catch (err) { + if (err instanceof AuthError) { + reply.code(401).send({ error: err.code }); + return; + } + throw err; + } + }); + + app.post("/refresh", async (request, reply) => { + const presented = readCookie(request.headers.cookie, REFRESH_COOKIE); + if (presented === undefined) { + reply.code(401).send({ error: "refresh_rejected" }); + return; + } + try { + const result = await refreshSession(getPool(), presented); + setRefreshCookie(reply, result.refreshToken); + reply.code(200).send({ accessToken: result.accessToken }); + } catch (err) { + if (err instanceof AuthError) { + clearRefreshCookie(reply); + reply.code(401).send({ error: err.code }); + return; + } + throw err; + } + }); + + app.post("/reset/request", async (request, reply) => { + const body = request.body as { email?: unknown } | undefined; + if (typeof body?.email !== "string") { + reply.code(400).send({ error: "invalid_email" }); + return; + } + await requestPasswordReset(getPool(), body.email); + reply.code(202).send({ message: "check your email" }); + }); + + app.post("/reset/confirm", async (request, reply) => { + const body = request.body as { token?: unknown; newPassword?: unknown } | undefined; + if (typeof body?.token !== "string") { + reply.code(400).send({ error: "invalid_or_used" }); + return; + } + try { + const newPassword = validateNewPassword(body.newPassword); + await confirmPasswordReset(getPool(), body.token, newPassword); + } catch (err) { + if (err instanceof ValidationError) { + reply.code(400).send({ error: err.code }); + return; + } + throw err; + } + reply.code(200).send({ status: "reset" }); + }); } diff --git a/apps/api/src/features/auth/service.ts b/apps/api/src/features/auth/service.ts index 9baf0c7..5763fb9 100644 --- a/apps/api/src/features/auth/service.ts +++ b/apps/api/src/features/auth/service.ts @@ -1,10 +1,34 @@ import argon2 from "argon2"; +import { randomUUID } from "node:crypto"; import type { Pool } from "pg"; +import { withTransaction } from "../../db.js"; import { isCommonPassword } from "./common-passwords.js"; -import { findUserIdByEmail, insertEmailOutbox, insertUserReturningId } from "./repo.js"; +import { signAccessToken } from "./jwt.js"; +import { + consumeEmailVerificationToken, + consumePasswordResetToken, + findUserForLogin, + findUserIdByEmail, + getAuthVersion, + insertAuthEvent, + insertEmailOutbox, + insertRefreshSession, + insertUserReturningId, + invalidateOtherResetTokens, + markEmailVerified, + markRefreshRotated, + revokeAllRefreshSessions, + revokeRefreshFamily, + selectRefreshSessionForUpdate, + updatePasswordAndBumpAuthVersion, +} from "./repo.js"; +import { hashToken, randomToken } from "./tokens.js"; const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; +const REFRESH_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days (§9) +const GRACE_WINDOW_MS = 30 * 1000; +const GRACE_MAX_CHILDREN = 3; export class ValidationError extends Error { constructor(public code: string) { @@ -12,29 +36,41 @@ export class ValidationError extends Error { } } +export class AuthError extends Error { + constructor(public code: string) { + super(code); + } +} + // argon2id, m = 19 MiB, t = 2, p = 1 (§9). const ARGON2_OPTIONS = { type: argon2.argon2id, memoryCost: 19 * 1024, timeCost: 2, parallelism: 1 }; -export function validateSignupInput(email: unknown, password: unknown): { email: string; password: string } { - if (typeof email !== "string" || !EMAIL_RE.test(email)) { - throw new ValidationError("invalid_email"); - } +// A fixed dummy hash, verified against on every login for an unknown email so that the +// response takes the same time as a real (wrong-password) verification (§9 equal timing). +const DUMMY_HASH = await argon2.hash("dummy-password-for-timing-safety", ARGON2_OPTIONS); + +function validatePassword(password: unknown): string { if (typeof password !== "string" || password.length < 10 || password.length > 128) { throw new ValidationError("invalid_password"); } if (isCommonPassword(password)) { throw new ValidationError("common_password"); } - return { email, password }; + return password; +} + +export function validateSignupInput(email: unknown, password: unknown): { email: string; password: string } { + if (typeof email !== "string" || !EMAIL_RE.test(email)) { + throw new ValidationError("invalid_email"); + } + return { email, password: validatePassword(password) }; } // Always succeeds from the caller's point of view (§9: signup never reveals whether an // email exists) — the 202 response is the same regardless of which branch runs below. export async function signup(pool: Pool, email: string, password: string): Promise { const passwordHash = await argon2.hash(password, ARGON2_OPTIONS); - const client = await pool.connect(); - try { - await client.query("BEGIN"); + await withTransaction(pool, async (client) => { const newUserId = await insertUserReturningId(client, email, passwordHash); if (newUserId !== null) { await insertEmailOutbox(client, newUserId, "verify_email"); @@ -44,11 +80,149 @@ export async function signup(pool: Pool, email: string, password: string): Promi // with this lower(email) already exists. await insertEmailOutbox(client, existingUserId as string, "account_exists"); } - await client.query("COMMIT"); - } catch (err) { - await client.query("ROLLBACK"); - throw err; - } finally { - client.release(); - } + }); +} + +// ───────────────────────── Task C: email verification ───────────────────────── + +export async function verifyEmail(pool: Pool, token: string): Promise { + const tokenHash = hashToken(token); + await withTransaction(pool, async (client) => { + const userId = await consumeEmailVerificationToken(client, tokenHash); + if (userId === null) throw new ValidationError("invalid_or_used"); + await markEmailVerified(client, userId); + await insertAuthEvent(client, userId, "email_verified"); + }); +} + +// ───────────────────────── Task D: login + JWT issuance ───────────────────────── + +export interface LoginResult { + accessToken: string; + refreshToken: string; +} + +// withTransaction commits whatever the callback returns and rolls back only on a throw — so +// a rejected login (which still must commit its login_fail audit row) can't signal failure by +// throwing. It returns a discriminated result instead; only after the commit do we turn an +// "ok: false" into the AuthError the route handler expects. +type LoginOutcome = { ok: true; result: LoginResult } | { ok: false; code: string }; + +export async function login(pool: Pool, email: string, password: string): Promise { + const outcome = await withTransaction(pool, async (client) => { + const user = await findUserForLogin(client, email); + // Verify against the dummy hash for an unknown email so the two failure paths take the + // same time (§9) — argon2.verify's cost depends only on the hash's params, not on match. + const passwordOk = await argon2.verify(user?.password_hash ?? DUMMY_HASH, password); + if (user === null || !passwordOk) { + await insertAuthEvent(client, user?.id ?? null, "login_fail"); + return { ok: false, code: "invalid_credentials" }; + } + + const familyId = randomUUID(); + const rawRefreshToken = randomToken(); + await insertRefreshSession(client, { + userId: user.id, + familyId, + parentId: null, + tokenHash: hashToken(rawRefreshToken), + expiresAt: new Date(Date.now() + REFRESH_TTL_MS), + }); + await insertAuthEvent(client, user.id, "login_ok", { familyId }); + + return { + ok: true, + result: { accessToken: signAccessToken(user.id, user.auth_version), refreshToken: rawRefreshToken }, + }; + }); + + if (!outcome.ok) throw new AuthError(outcome.code); + return outcome.result; +} + +// ───────────────────────── Task F: refresh rotation ───────────────────────── + +export interface RefreshResult { + accessToken: string; + refreshToken: string; +} + +// Same reasoning as login's outcome type: the reuse-revoked branch's revocation (and the +// rejected branch's audit row) must survive the commit even though the caller is told it +// failed, so failure is a returned value here, never a throw inside the transaction. +type RefreshOutcome = { ok: true; result: RefreshResult } | { ok: false; code: string }; + +// Exactly the state table in §9: unknown/expired/revoked → reject; never rotated → rotate; +// rotated ≤ 30s ago with < 3 children → grace sibling; otherwise → reuse, revoke the family. +export async function refresh(pool: Pool, presentedToken: string): Promise { + const presentedHash = hashToken(presentedToken); + const outcome = await withTransaction(pool, async (client) => { + const row = await selectRefreshSessionForUpdate(client, presentedHash); + if (row === null) { + await insertAuthEvent(client, null, "refresh_rejected", { reason: "unknown_expired_or_revoked" }); + return { ok: false, code: "refresh_rejected" }; + } + + const now = Date.now(); + const rotatedRecently = row.rotated_at !== null && now - row.rotated_at.getTime() <= GRACE_WINDOW_MS; + + if (row.rotated_at === null) { + await markRefreshRotated(client, row.id); + await insertAuthEvent(client, row.user_id, "refresh_rotate", { familyId: row.family_id }); + } else if (rotatedRecently && row.children < GRACE_MAX_CHILDREN) { + await insertAuthEvent(client, row.user_id, "refresh_grace", { familyId: row.family_id }); + } else { + await revokeRefreshFamily(client, row.family_id); + await insertAuthEvent(client, row.user_id, "refresh_reuse_revoked", { familyId: row.family_id }); + return { ok: false, code: "refresh_reuse_revoked" }; + } + + const rawChildToken = randomToken(); + await insertRefreshSession(client, { + userId: row.user_id, + familyId: row.family_id, + parentId: row.id, + tokenHash: hashToken(rawChildToken), + expiresAt: new Date(now + REFRESH_TTL_MS), + }); + + const authVersion = await getAuthVersion(client, row.user_id); + return { + ok: true, + result: { accessToken: signAccessToken(row.user_id, authVersion as number), refreshToken: rawChildToken }, + }; + }); + + if (!outcome.ok) throw new AuthError(outcome.code); + return outcome.result; +} + +// ───────────────────────── Task G: password reset ───────────────────────── + +export async function requestPasswordReset(pool: Pool, email: string): Promise { + // Mirrors signup's existence-hiding shape: always succeeds; only writes an outbox row + // when the account exists, and the caller can't tell the difference either way. + await withTransaction(pool, async (client) => { + const userId = await findUserIdByEmail(client, email); + if (userId !== null) { + await insertEmailOutbox(client, userId, "password_reset"); + } + }); +} + +export function validateNewPassword(password: unknown): string { + return validatePassword(password); +} + +export async function confirmPasswordReset(pool: Pool, token: string, newPassword: string): Promise { + const tokenHash = hashToken(token); + const passwordHash = await argon2.hash(newPassword, ARGON2_OPTIONS); + await withTransaction(pool, async (client) => { + const userId = await consumePasswordResetToken(client, tokenHash); + if (userId === null) throw new ValidationError("invalid_or_used"); + await updatePasswordAndBumpAuthVersion(client, userId, passwordHash); + await invalidateOtherResetTokens(client, userId); + await revokeAllRefreshSessions(client, userId); + await insertAuthEvent(client, userId, "password_reset"); + }); } diff --git a/apps/api/src/features/auth/tokens.ts b/apps/api/src/features/auth/tokens.ts new file mode 100644 index 0000000..f5e1771 --- /dev/null +++ b/apps/api/src/features/auth/tokens.ts @@ -0,0 +1,11 @@ +// Opaque tokens for email verification / password reset / refresh (§9): a random 256-bit +// value is handed to the client; only its SHA-256 hash is ever stored. +import { createHash, randomBytes } from "node:crypto"; + +export function randomToken(): string { + return randomBytes(32).toString("base64url"); +} + +export function hashToken(raw: string): string { + return createHash("sha256").update(raw).digest("hex"); +} diff --git a/apps/api/src/plugins/auth.ts b/apps/api/src/plugins/auth.ts index 00f0d78..1a1bac5 100644 --- a/apps/api/src/plugins/auth.ts +++ b/apps/api/src/plugins/auth.ts @@ -1,9 +1,52 @@ -// /api/* scope: verifies the access JWT and checks `av` against users.auth_version. -// Stub for the skeleton — real verification lands in the login/JWT-issuance task. -import type { FastifyInstance } from "fastify"; +// /api/* scope: verifies the access JWT and checks `av` against users.auth_version (§9). +// Direct Postgres read for now — the Redis ≤30s cache is added in the rate-limits/Redis task. +import type { FastifyInstance, FastifyRequest } from "fastify"; + +import { getPool } from "../db.js"; +import { AccessTokenError, type AccessTokenClaims, verifyAccessToken } from "../features/auth/jwt.js"; + +declare module "fastify" { + interface FastifyRequest { + userId?: string; + } +} + +async function currentAuthVersion(userId: string): Promise { + const result = await getPool().query<{ auth_version: number }>( + "SELECT auth_version FROM users WHERE id = $1", + [userId] + ); + return result.rows[0]?.auth_version ?? null; +} export async function authPlugin(app: FastifyInstance): Promise { - app.addHook("onRequest", async (_request, _reply) => { - // TODO: verify JWT (kid, sub, av, exp) and check av === users.auth_version. + app.addHook("onRequest", async (request: FastifyRequest, reply) => { + const header = request.headers.authorization; + const token = header?.startsWith("Bearer ") ? header.slice("Bearer ".length) : undefined; + if (token === undefined) { + reply.code(401).send({ error: "unauthorized" }); + return reply; + } + + let claims: AccessTokenClaims; + try { + claims = verifyAccessToken(token); + } catch (err) { + if (err instanceof AccessTokenError) { + reply.code(401).send({ error: "unauthorized" }); + return reply; + } + throw err; + } + + const authVersion = await currentAuthVersion(claims.sub); + if (authVersion === null || authVersion !== claims.av) { + // ponytail: §9 wants this logged sampled at max 1/min/user; plain logging is fine + // until log volume is a problem (watch it in the runbooks pass before launch). + reply.code(401).send({ error: "access_version_mismatch" }); + return reply; + } + + request.userId = claims.sub; }); } diff --git a/apps/api/test/auth-plugin.test.ts b/apps/api/test/auth-plugin.test.ts new file mode 100644 index 0000000..d34ca5a --- /dev/null +++ b/apps/api/test/auth-plugin.test.ts @@ -0,0 +1,64 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { buildApp } from "../src/app.js"; +import { getPool } from "../src/db.js"; +import { signAccessToken } from "../src/features/auth/jwt.js"; +import { cleanupTestUser, createUser } from "./helpers.js"; + +const EMAIL = "auth-plugin-test@mail.example"; + +beforeEach(() => cleanupTestUser(EMAIL)); +afterEach(() => cleanupTestUser(EMAIL)); + +describe("/api/* auth plugin", () => { + it("rejects a request with no Authorization header", async () => { + const app = buildApp(); + const res = await app.inject({ method: "GET", url: "/api/whoami" }); + expect(res.statusCode).toBe(401); + expect(res.json()).toEqual({ error: "unauthorized" }); + await app.close(); + }); + + it("rejects a malformed token", async () => { + const app = buildApp(); + const res = await app.inject({ + method: "GET", + url: "/api/whoami", + headers: { authorization: "Bearer not-a-jwt" }, + }); + expect(res.statusCode).toBe(401); + expect(res.json()).toEqual({ error: "unauthorized" }); + await app.close(); + }); + + it("lets a valid token through and attaches request.userId", async () => { + const userId = await createUser(EMAIL, "a-strong-passphrase"); + const token = signAccessToken(userId, 1); + const app = buildApp(); + + const res = await app.inject({ + method: "GET", + url: "/api/whoami", + headers: { authorization: `Bearer ${token}` }, + }); + expect(res.statusCode).toBe(200); + expect(res.json()).toEqual({ userId }); + await app.close(); + }); + + it("rejects a token whose av no longer matches auth_version (bumped since issue)", async () => { + const userId = await createUser(EMAIL, "a-strong-passphrase"); + const token = signAccessToken(userId, 1); + await getPool().query("UPDATE users SET auth_version = auth_version + 1 WHERE id = $1", [userId]); + const app = buildApp(); + + const res = await app.inject({ + method: "GET", + url: "/api/whoami", + headers: { authorization: `Bearer ${token}` }, + }); + expect(res.statusCode).toBe(401); + expect(res.json()).toEqual({ error: "access_version_mismatch" }); + await app.close(); + }); +}); diff --git a/apps/api/test/helpers.ts b/apps/api/test/helpers.ts new file mode 100644 index 0000000..d963b4d --- /dev/null +++ b/apps/api/test/helpers.ts @@ -0,0 +1,46 @@ +// Test-only helpers. The mailer (Task I) is what mints verification/reset tokens in +// production; until it exists, tests mint them directly the same way it will. +import argon2 from "argon2"; + +import { getPool } from "../src/db.js"; +import { hashToken, randomToken } from "../src/features/auth/tokens.js"; + +export async function createUser(email: string, password: string): Promise { + const passwordHash = await argon2.hash(password, { + type: argon2.argon2id, + memoryCost: 19 * 1024, + timeCost: 2, + parallelism: 1, + }); + const result = await getPool().query<{ id: string }>( + "INSERT INTO users (email, password_hash) VALUES ($1, $2) RETURNING id", + [email, passwordHash] + ); + return result.rows[0].id; +} + +export async function mintEmailVerificationToken(userId: string, expiresInMs = 24 * 60 * 60 * 1000): Promise { + const token = randomToken(); + await getPool().query( + "INSERT INTO email_verification_tokens (user_id, token_hash, expires_at) VALUES ($1, $2, $3)", + [userId, hashToken(token), new Date(Date.now() + expiresInMs)] + ); + return token; +} + +export async function mintPasswordResetToken(userId: string, expiresInMs = 30 * 60 * 1000): Promise { + const token = randomToken(); + await getPool().query( + "INSERT INTO password_reset_tokens (user_id, token_hash, expires_at) VALUES ($1, $2, $3)", + [userId, hashToken(token), new Date(Date.now() + expiresInMs)] + ); + return token; +} + +export async function cleanupTestUser(email: string): Promise { + await getPool().query( + "DELETE FROM auth_events WHERE user_id IN (SELECT id FROM users WHERE lower(email) = lower($1))", + [email] + ); + await getPool().query("DELETE FROM users WHERE lower(email) = lower($1)", [email]); +} diff --git a/apps/api/test/login.test.ts b/apps/api/test/login.test.ts new file mode 100644 index 0000000..36cb907 --- /dev/null +++ b/apps/api/test/login.test.ts @@ -0,0 +1,51 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { buildApp, WEB_ORIGIN } from "../src/app.js"; +import { cleanupTestUser, createUser } from "./helpers.js"; + +const EMAIL = "login-test@mail.example"; +const PASSWORD = "a-strong-passphrase"; + +beforeEach(() => cleanupTestUser(EMAIL)); +afterEach(() => cleanupTestUser(EMAIL)); + +function post(app: ReturnType, body: unknown) { + return app.inject({ + method: "POST", + url: "/auth/login", + headers: { origin: WEB_ORIGIN, "content-type": "application/json" }, + payload: body as object, + }); +} + +describe("POST /auth/login", () => { + it("issues an access token and a refresh cookie on correct credentials", async () => { + await createUser(EMAIL, PASSWORD); + const app = buildApp(); + + const res = await post(app, { email: EMAIL, password: PASSWORD }); + expect(res.statusCode).toBe(200); + expect(typeof res.json().accessToken).toBe("string"); + + const cookie = res.headers["set-cookie"] as string; + expect(cookie).toContain("refresh_token="); + expect(cookie).toContain("Path=/auth/refresh"); + expect(cookie).toContain("HttpOnly"); + expect(cookie).toContain("SameSite=Strict"); + await app.close(); + }); + + it("returns the same generic error for wrong password and unknown email", async () => { + await createUser(EMAIL, PASSWORD); + const app = buildApp(); + + const wrongPassword = await post(app, { email: EMAIL, password: "totally-wrong-password" }); + const unknownEmail = await post(app, { email: "nobody-here@mail.example", password: PASSWORD }); + + expect(wrongPassword.statusCode).toBe(401); + expect(unknownEmail.statusCode).toBe(401); + expect(wrongPassword.json()).toEqual(unknownEmail.json()); + expect(wrongPassword.json()).toEqual({ error: "invalid_credentials" }); + await app.close(); + }); +}); diff --git a/apps/api/test/refresh.test.ts b/apps/api/test/refresh.test.ts new file mode 100644 index 0000000..152ba2e --- /dev/null +++ b/apps/api/test/refresh.test.ts @@ -0,0 +1,72 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { buildApp, WEB_ORIGIN } from "../src/app.js"; +import { cleanupTestUser, createUser } from "./helpers.js"; + +const EMAIL = "refresh-test@mail.example"; +const PASSWORD = "a-strong-passphrase"; + +beforeEach(() => cleanupTestUser(EMAIL)); +afterEach(() => cleanupTestUser(EMAIL)); + +function cookiePair(setCookieHeader: string): string { + return setCookieHeader.split(";")[0]; +} + +async function loginAndGetRefreshCookie(app: ReturnType): Promise { + const res = await app.inject({ + method: "POST", + url: "/auth/login", + headers: { origin: WEB_ORIGIN, "content-type": "application/json" }, + payload: { email: EMAIL, password: PASSWORD }, + }); + return cookiePair(res.headers["set-cookie"] as string); +} + +function refreshWith(app: ReturnType, cookie: string) { + return app.inject({ + method: "POST", + url: "/auth/refresh", + headers: { origin: WEB_ORIGIN, "content-type": "application/json", cookie }, + payload: {}, + }); +} + +describe("POST /auth/refresh", () => { + it("rejects a missing or unknown refresh token", async () => { + const app = buildApp(); + const res = await refreshWith(app, "refresh_token=garbage"); + expect(res.statusCode).toBe(401); + expect(res.json()).toEqual({ error: "refresh_rejected" }); + await app.close(); + }); + + it("rotates on first use, grants grace siblings, then revokes the family on reuse past the limit", async () => { + await createUser(EMAIL, PASSWORD); + const app = buildApp(); + const original = await loginAndGetRefreshCookie(app); + + // First use: genuine rotation. + const first = await refreshWith(app, original); + expect(first.statusCode).toBe(200); + + // Re-presenting the already-rotated original twice more: grace (lost-response retries). + const grace1 = await refreshWith(app, original); + expect(grace1.statusCode).toBe(200); + const grace2 = await refreshWith(app, original); + expect(grace2.statusCode).toBe(200); + + // A fourth presentation exceeds the 3-children grace limit: reuse, family revoked. + const reuse = await refreshWith(app, original); + expect(reuse.statusCode).toBe(401); + expect(reuse.json()).toEqual({ error: "refresh_reuse_revoked" }); + + // The legitimately-rotated child from the first call is now revoked too (whole family). + const childCookie = cookiePair(first.headers["set-cookie"] as string); + const afterRevocation = await refreshWith(app, childCookie); + expect(afterRevocation.statusCode).toBe(401); + expect(afterRevocation.json()).toEqual({ error: "refresh_rejected" }); + + await app.close(); + }); +}); diff --git a/apps/api/test/reset.test.ts b/apps/api/test/reset.test.ts new file mode 100644 index 0000000..8cd0c5a --- /dev/null +++ b/apps/api/test/reset.test.ts @@ -0,0 +1,79 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { buildApp, WEB_ORIGIN } from "../src/app.js"; +import { getPool } from "../src/db.js"; +import { cleanupTestUser, createUser, mintPasswordResetToken } from "./helpers.js"; + +const EMAIL = "reset-test@mail.example"; +const OLD_PASSWORD = "a-strong-passphrase"; +const NEW_PASSWORD = "a-different-strong-passphrase"; + +beforeEach(() => cleanupTestUser(EMAIL)); +afterEach(() => cleanupTestUser(EMAIL)); + +function post(app: ReturnType, url: string, body: unknown) { + return app.inject({ + method: "POST", + url, + headers: { origin: WEB_ORIGIN, "content-type": "application/json" }, + payload: body as object, + }); +} + +describe("POST /auth/reset/request", () => { + it("queues a password_reset outbox row for an existing account", async () => { + const userId = await createUser(EMAIL, OLD_PASSWORD); + const app = buildApp(); + const res = await post(app, "/auth/reset/request", { email: EMAIL }); + expect(res.statusCode).toBe(202); + + const outbox = await getPool().query("SELECT kind FROM email_outbox WHERE user_id = $1", [userId]); + expect(outbox.rows).toEqual([{ kind: "password_reset" }]); + await app.close(); + }); + + it("still returns 202 for an email that has no account", async () => { + const app = buildApp(); + const res = await post(app, "/auth/reset/request", { email: "nobody-here@mail.example" }); + expect(res.statusCode).toBe(202); + await app.close(); + }); +}); + +describe("POST /auth/reset/confirm", () => { + it("changes the password, bumps auth_version, and revokes existing sessions", async () => { + const userId = await createUser(EMAIL, OLD_PASSWORD); + const app = buildApp(); + + const loginRes = await post(app, "/auth/login", { email: EMAIL, password: OLD_PASSWORD }); + const refreshCookie = (loginRes.headers["set-cookie"] as string).split(";")[0]; + + const token = await mintPasswordResetToken(userId); + const confirmRes = await post(app, "/auth/reset/confirm", { token, newPassword: NEW_PASSWORD }); + expect(confirmRes.statusCode).toBe(200); + + const oldPasswordLogin = await post(app, "/auth/login", { email: EMAIL, password: OLD_PASSWORD }); + expect(oldPasswordLogin.statusCode).toBe(401); + + const newPasswordLogin = await post(app, "/auth/login", { email: EMAIL, password: NEW_PASSWORD }); + expect(newPasswordLogin.statusCode).toBe(200); + + const revokedRefresh = await app.inject({ + method: "POST", + url: "/auth/refresh", + headers: { origin: WEB_ORIGIN, "content-type": "application/json", cookie: refreshCookie }, + payload: {}, + }); + expect(revokedRefresh.statusCode).toBe(401); + + await app.close(); + }); + + it("rejects an invalid token", async () => { + const app = buildApp(); + const res = await post(app, "/auth/reset/confirm", { token: "nope", newPassword: NEW_PASSWORD }); + expect(res.statusCode).toBe(400); + expect(res.json()).toEqual({ error: "invalid_or_used" }); + await app.close(); + }); +}); diff --git a/apps/api/test/verify.test.ts b/apps/api/test/verify.test.ts new file mode 100644 index 0000000..e2a963d --- /dev/null +++ b/apps/api/test/verify.test.ts @@ -0,0 +1,57 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { buildApp, WEB_ORIGIN } from "../src/app.js"; +import { getPool } from "../src/db.js"; +import { cleanupTestUser, createUser, mintEmailVerificationToken } from "./helpers.js"; + +const EMAIL = "verify-test@mail.example"; + +beforeEach(() => cleanupTestUser(EMAIL)); +afterEach(() => cleanupTestUser(EMAIL)); + +function post(app: ReturnType, body: unknown) { + return app.inject({ + method: "POST", + url: "/auth/verify", + headers: { origin: WEB_ORIGIN, "content-type": "application/json" }, + payload: body as object, + }); +} + +describe("POST /auth/verify", () => { + it("sets email_verified_at and consumes the token", async () => { + const userId = await createUser(EMAIL, "a-strong-passphrase"); + const token = await mintEmailVerificationToken(userId); + const app = buildApp(); + + const res = await post(app, { token }); + expect(res.statusCode).toBe(200); + + const user = await getPool().query("SELECT email_verified_at FROM users WHERE id = $1", [userId]); + expect(user.rows[0].email_verified_at).not.toBeNull(); + + // Token is single-use. + const second = await post(app, { token }); + expect(second.statusCode).toBe(400); + expect(second.json()).toEqual({ error: "invalid_or_used" }); + await app.close(); + }); + + it("rejects an expired token", async () => { + const userId = await createUser(EMAIL, "a-strong-passphrase"); + const token = await mintEmailVerificationToken(userId, -1000); + const app = buildApp(); + + const res = await post(app, { token }); + expect(res.statusCode).toBe(400); + expect(res.json()).toEqual({ error: "invalid_or_used" }); + await app.close(); + }); + + it("rejects an unknown token", async () => { + const app = buildApp(); + const res = await post(app, { token: "not-a-real-token" }); + expect(res.statusCode).toBe(400); + await app.close(); + }); +}); From 08d3b938317c2b9f726bfaaf72fb3bede845ebf7 Mon Sep 17 00:00:00 2001 From: saran Date: Sat, 10 Oct 2026 18:43:37 +0530 Subject: [PATCH 05/12] added rate limits using redis and a workflow for processing mails --- apps/api/package-lock.json | 91 ++++++++++++++++++++ apps/api/package.json | 1 + apps/api/src/app.ts | 16 +++- apps/api/src/config.ts | 1 + apps/api/src/db.ts | 28 ++++-- apps/api/src/features/auth/routes.ts | 41 ++++++++- apps/api/src/mailer/index.ts | 29 +++++++ apps/api/src/mailer/repo.ts | 75 ++++++++++++++++ apps/api/src/mailer/service.ts | 77 +++++++++++++++++ apps/api/src/mailer/transport.ts | 21 +++++ apps/api/src/rate-limit.ts | 49 +++++++++++ apps/api/src/redis.ts | 22 +++++ apps/api/test/global-setup.ts | 11 +++ apps/api/test/mailer.test.ts | 123 +++++++++++++++++++++++++++ apps/api/test/rate-limit.test.ts | 21 +++++ apps/api/test/signup.test.ts | 21 +++++ apps/api/vitest.config.ts | 13 +++ infra/docker-compose.yml | 24 ++++++ 18 files changed, 653 insertions(+), 11 deletions(-) create mode 100644 apps/api/src/config.ts create mode 100644 apps/api/src/mailer/index.ts create mode 100644 apps/api/src/mailer/repo.ts create mode 100644 apps/api/src/mailer/service.ts create mode 100644 apps/api/src/mailer/transport.ts create mode 100644 apps/api/src/rate-limit.ts create mode 100644 apps/api/src/redis.ts create mode 100644 apps/api/test/global-setup.ts create mode 100644 apps/api/test/mailer.test.ts create mode 100644 apps/api/test/rate-limit.test.ts create mode 100644 apps/api/vitest.config.ts diff --git a/apps/api/package-lock.json b/apps/api/package-lock.json index b0590b5..19c5b79 100644 --- a/apps/api/package-lock.json +++ b/apps/api/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "argon2": "^0.41.1", "fastify": "^5.1.0", + "ioredis": "^5.4.1", "jsonwebtoken": "^9.0.2", "pg": "^8.13.1" }, @@ -591,6 +592,12 @@ "ipaddr.js": "^2.1.0" } }, + "node_modules/@ioredis/commands": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.10.0.tgz", + "integrity": "sha512-UmeW7z4LfctwoQ5wkhVzgq8tXkreED2xZGpX+Bg+zA+WJFZCT6c062AfCK/Dfk81xZnnwdhJCUMkitihRaoC2Q==", + "license": "MIT" + }, "node_modules/@jridgewell/resolve-uri": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", @@ -1151,6 +1158,15 @@ "node": ">=18" } }, + "node_modules/cluster-key-slot": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.1.tgz", + "integrity": "sha512-rwHwUfXL40Chm1r08yrhU3qpUvdVlgkKNeyeGPOxnW8/SyVDvgRaed/Uz54AqWNaTCAThlj6QAs3TZcKI0xDEw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/cookie": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/cookie/-/cookie-2.0.1.tgz", @@ -1164,6 +1180,32 @@ "url": "https://opencollective.com/express" } }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10" + } + }, "node_modules/dequal": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", @@ -1412,6 +1454,28 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/ioredis": { + "version": "5.11.1", + "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.11.1.tgz", + "integrity": "sha512-ehuGcf94bQXhfagULNXrJdfnWO38v070jxSx/qE87Kjzmu2fU7ro5EFAb+OPituLqgfyuQaym5DlrNydW2sJ9A==", + "license": "MIT", + "dependencies": { + "@ioredis/commands": "1.10.0", + "cluster-key-slot": "1.1.1", + "debug": "4.4.3", + "denque": "2.1.0", + "redis-errors": "1.2.0", + "redis-parser": "3.0.0", + "standard-as-callback": "2.1.0" + }, + "engines": { + "node": ">=12.22.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/ioredis" + } + }, "node_modules/ipaddr.js": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.5.0.tgz", @@ -2149,6 +2213,27 @@ "integrity": "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==", "license": "MIT" }, + "node_modules/redis-errors": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz", + "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/redis-parser": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz", + "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==", + "license": "MIT", + "dependencies": { + "redis-errors": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", @@ -2332,6 +2417,12 @@ "node": ">= 10.x" } }, + "node_modules/standard-as-callback": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz", + "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==", + "license": "MIT" + }, "node_modules/std-env": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.3.0.tgz", diff --git a/apps/api/package.json b/apps/api/package.json index 805216a..da8d68d 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -12,6 +12,7 @@ "dependencies": { "argon2": "^0.41.1", "fastify": "^5.1.0", + "ioredis": "^5.4.1", "jsonwebtoken": "^9.0.2", "pg": "^8.13.1" }, diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index a80be27..8a03360 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -2,16 +2,26 @@ // "cache isolation is structural, not a convention") — /auth/*, /api/*, /api/public/*. import Fastify, { type FastifyInstance } from "fastify"; +import { WEB_ORIGIN } from "./config.js"; import { authRoutes } from "./features/auth/routes.js"; import { authPlugin } from "./plugins/auth.js"; import { cachePlugin } from "./plugins/cache.js"; +export { WEB_ORIGIN } from "./config.js"; + const MUTATING_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]); -export const WEB_ORIGIN = process.env.WEB_ORIGIN ?? "http://localhost:5173"; +// §9 asks for client IP = the address N hops back in X-Forwarded-For via Fastify's +// `trustProxy: N`. This Fastify version removed numeric hop-count trust: it can't validate +// the immediate peer, so a direct client could spoof X-Forwarded-For to fake N hops. The +// secure equivalent it recommends is trusting the proxy's actual IP/CIDR instead — set +// TRUSTED_PROXY to that address (or "true" to trust any peer, only for local dev). +const TRUSTED_PROXY_RAW = process.env.TRUSTED_PROXY; +const TRUSTED_PROXY: boolean | string = + TRUSTED_PROXY_RAW === undefined ? false : TRUSTED_PROXY_RAW === "true" ? true : TRUSTED_PROXY_RAW; export function buildApp(): FastifyInstance { - const app = Fastify({ logger: true }); + const app = Fastify({ logger: true, trustProxy: TRUSTED_PROXY }); // CSRF defense in depth (§9): SameSite=Strict cookies plus an exact-Origin check plus a // JSON-only content type on every state-changing request. @@ -39,7 +49,7 @@ export function buildApp(): FastifyInstance { app.get("/health", async () => ({ status: "ok" })); - // /auth/* — neither plugin; CSRF checks and rate limits only (rate limits: later task). + // /auth/* — neither plugin; CSRF checks and rate limits only (rate limits: inside authRoutes). app.register(authRoutes, { prefix: "/auth" }); // /api/public/* — cache plugin, no auth plugin: handlers here cannot read user identity. diff --git a/apps/api/src/config.ts b/apps/api/src/config.ts new file mode 100644 index 0000000..c479d75 --- /dev/null +++ b/apps/api/src/config.ts @@ -0,0 +1 @@ +export const WEB_ORIGIN = process.env.WEB_ORIGIN ?? "http://localhost:5173"; diff --git a/apps/api/src/db.ts b/apps/api/src/db.ts index 4be164a..3cfacc0 100644 --- a/apps/api/src/db.ts +++ b/apps/api/src/db.ts @@ -19,16 +19,30 @@ export function getPool(): Pool { return pool; } +const MAX_TRANSACTION_RETRIES = 3; + +// Retries on deadlock_detected / serialization_failure: normal, expected outcomes under +// concurrent transactional load, not bugs — Postgres's own docs recommend retrying these. +function isRetryableTransactionError(err: unknown): boolean { + const code = (err as { code?: string } | null)?.code; + return code === "40P01" || code === "40001"; +} + export async function withTransaction(pool: Pool, fn: (client: PoolClient) => Promise): Promise { const client = await pool.connect(); try { - await client.query("BEGIN"); - const result = await fn(client); - await client.query("COMMIT"); - return result; - } catch (err) { - await client.query("ROLLBACK"); - throw err; + for (let attempt = 1; ; attempt++) { + try { + await client.query("BEGIN"); + const result = await fn(client); + await client.query("COMMIT"); + return result; + } catch (err) { + await client.query("ROLLBACK"); + if (isRetryableTransactionError(err) && attempt < MAX_TRANSACTION_RETRIES) continue; + throw err; + } + } } finally { client.release(); } diff --git a/apps/api/src/features/auth/routes.ts b/apps/api/src/features/auth/routes.ts index 095bcdf..2dc3210 100644 --- a/apps/api/src/features/auth/routes.ts +++ b/apps/api/src/features/auth/routes.ts @@ -1,7 +1,8 @@ -import type { FastifyInstance, FastifyReply } from "fastify"; +import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; import { buildSetCookie, readCookie } from "../../cookie.js"; import { getPool } from "../../db.js"; +import { checkAllLimits, type LimitSpec } from "../../rate-limit.js"; import { AuthError, confirmPasswordReset, @@ -14,11 +15,23 @@ import { ValidationError, verifyEmail, } from "./service.js"; +import { hashToken } from "./tokens.js"; const REFRESH_COOKIE = "refresh_token"; const REFRESH_COOKIE_PATH = "/auth/refresh"; const REFRESH_MAX_AGE_SECONDS = 30 * 24 * 60 * 60; +// Returns true when within limits (and the attempt is already counted). Sends 429 otherwise. +async function enforceRateLimits(reply: FastifyReply, specs: LimitSpec[]): Promise { + if (await checkAllLimits(specs)) return true; + reply.code(429).send({ error: "rate_limited" }); + return false; +} + +function ipSpec(request: FastifyRequest, endpoint: string, limit: number, windowSeconds: number): LimitSpec { + return { key: `${endpoint}:ip:${request.ip}`, limit, windowSeconds }; +} + function setRefreshCookie(reply: FastifyReply, token: string): void { reply.header( "set-cookie", @@ -33,6 +46,12 @@ function clearRefreshCookie(reply: FastifyReply): void { export async function authRoutes(app: FastifyInstance): Promise { app.post("/signup", async (request, reply) => { const body = request.body as { email?: unknown; password?: unknown } | undefined; + const specs: LimitSpec[] = [ipSpec(request, "signup", 60, 60)]; + if (typeof body?.email === "string") { + specs.push({ key: `signup:email:${body.email.toLowerCase()}`, limit: 3, windowSeconds: 3600 }); + } + if (!(await enforceRateLimits(reply, specs))) return; + try { const { email, password } = validateSignupInput(body?.email, body?.password); await signup(getPool(), email, password); @@ -70,6 +89,14 @@ export async function authRoutes(app: FastifyInstance): Promise { reply.code(401).send({ error: "invalid_credentials" }); return; } + const email = body.email.toLowerCase(); + const specs: LimitSpec[] = [ + ipSpec(request, "login", 300, 60), + { key: `login:email:${email}:min`, limit: 5, windowSeconds: 60 }, + { key: `login:email:${email}:hour`, limit: 20, windowSeconds: 3600 }, + ]; + if (!(await enforceRateLimits(reply, specs))) return; + try { const result = await login(getPool(), body.email, body.password); setRefreshCookie(reply, result.refreshToken); @@ -89,6 +116,12 @@ export async function authRoutes(app: FastifyInstance): Promise { reply.code(401).send({ error: "refresh_rejected" }); return; } + const specs: LimitSpec[] = [ + ipSpec(request, "refresh", 600, 60), + { key: `refresh:token:${hashToken(presented)}`, limit: 10, windowSeconds: 60 }, + ]; + if (!(await enforceRateLimits(reply, specs))) return; + try { const result = await refreshSession(getPool(), presented); setRefreshCookie(reply, result.refreshToken); @@ -109,6 +142,12 @@ export async function authRoutes(app: FastifyInstance): Promise { reply.code(400).send({ error: "invalid_email" }); return; } + const specs: LimitSpec[] = [ + ipSpec(request, "reset-request", 120, 60), + { key: `reset-request:email:${body.email.toLowerCase()}`, limit: 3, windowSeconds: 3600 }, + ]; + if (!(await enforceRateLimits(reply, specs))) return; + await requestPasswordReset(getPool(), body.email); reply.code(202).send({ message: "check your email" }); }); diff --git a/apps/api/src/mailer/index.ts b/apps/api/src/mailer/index.ts new file mode 100644 index 0000000..9d79590 --- /dev/null +++ b/apps/api/src/mailer/index.ts @@ -0,0 +1,29 @@ +// Separate process, same API image (§9: `node mailer.js`). Drains the outbox backlog, then +// idles — a 10s poll tick, not a leader-elected job: a single mailer instance is the MVP +// design (complexity budget §1), so there's no multi-worker contention to guard against. +import { getPool } from "../db.js"; +import { WEB_ORIGIN } from "../config.js"; +import { processOnce } from "./service.js"; +import { ConsoleMailTransport } from "./transport.js"; + +const TICK_MS = 10_000; +const transport = new ConsoleMailTransport(); + +async function drain(): Promise { + while (await processOnce(getPool(), transport, WEB_ORIGIN)) { + // keep draining while rows are due + } +} + +async function main(): Promise { + for (;;) { + try { + await drain(); + } catch (err) { + console.error("[mailer] tick failed", err); + } + await new Promise((resolve) => setTimeout(resolve, TICK_MS)); + } +} + +main(); diff --git a/apps/api/src/mailer/repo.ts b/apps/api/src/mailer/repo.ts new file mode 100644 index 0000000..10b886e --- /dev/null +++ b/apps/api/src/mailer/repo.ts @@ -0,0 +1,75 @@ +import type { PoolClient } from "pg"; + +export interface OutboxRow { + id: string; + user_id: string; + kind: "verify_email" | "password_reset" | "account_exists"; + attempts: number; +} + +export async function claimDueOutboxRow(client: PoolClient): Promise { + const result = await client.query( + `SELECT id, user_id, kind, attempts FROM email_outbox + WHERE status = 'pending' AND next_attempt_at <= now() + ORDER BY next_attempt_at + LIMIT 1 + FOR UPDATE SKIP LOCKED` + ); + return result.rows[0] ?? null; +} + +// email_outbox.user_id is ON DELETE CASCADE, so a claimed row's user is guaranteed to exist — +// this throws only if that invariant is ever violated, which would indicate real corruption. +export async function getUserEmail(client: PoolClient, userId: string): Promise { + const result = await client.query<{ email: string }>("SELECT email FROM users WHERE id = $1", [userId]); + const email = result.rows[0]?.email; + if (email === undefined) throw new Error(`mailer: no user row for id ${userId} (outbox FK invariant violated)`); + return email; +} + +export async function insertEmailVerificationToken( + client: PoolClient, + userId: string, + tokenHash: string, + expiresAt: Date +): Promise { + await client.query( + "INSERT INTO email_verification_tokens (user_id, token_hash, expires_at) VALUES ($1, $2, $3)", + [userId, tokenHash, expiresAt] + ); +} + +export async function insertPasswordResetToken( + client: PoolClient, + userId: string, + tokenHash: string, + expiresAt: Date +): Promise { + await client.query( + "INSERT INTO password_reset_tokens (user_id, token_hash, expires_at) VALUES ($1, $2, $3)", + [userId, tokenHash, expiresAt] + ); +} + +export async function markOutboxSent(client: PoolClient, id: string): Promise { + await client.query("UPDATE email_outbox SET status = 'sent', sent_at = now() WHERE id = $1", [id]); +} + +export async function markOutboxFailed(client: PoolClient, id: string, lastError: string): Promise { + await client.query("UPDATE email_outbox SET status = 'failed', attempts = attempts + 1, last_error = $2 WHERE id = $1", [ + id, + lastError, + ]); +} + +export async function rescheduleOutbox( + client: PoolClient, + id: string, + nextAttemptAt: Date, + lastError: string +): Promise { + await client.query( + "UPDATE email_outbox SET attempts = attempts + 1, next_attempt_at = $2, last_error = $3 WHERE id = $1", + [id, nextAttemptAt, lastError] + ); +} diff --git a/apps/api/src/mailer/service.ts b/apps/api/src/mailer/service.ts new file mode 100644 index 0000000..b50c96d --- /dev/null +++ b/apps/api/src/mailer/service.ts @@ -0,0 +1,77 @@ +import type { Pool } from "pg"; + +import { withTransaction } from "../db.js"; +import { hashToken, randomToken } from "../features/auth/tokens.js"; +import { + claimDueOutboxRow, + getUserEmail, + insertEmailVerificationToken, + insertPasswordResetToken, + markOutboxFailed, + markOutboxSent, + rescheduleOutbox, +} from "./repo.js"; +import type { MailTransport } from "./transport.js"; + +// §9 retries: 1, 5, 30, 120, 360 min between attempts; a 6th failure gives up. +const BACKOFF_MINUTES = [1, 5, 30, 120, 360]; +const VERIFY_TOKEN_TTL_MS = 24 * 60 * 60 * 1000; +const RESET_TOKEN_TTL_MS = 30 * 60 * 1000; + +function buildMessage(kind: "verify_email" | "password_reset" | "account_exists", token: string, webOrigin: string) { + switch (kind) { + case "verify_email": + return { subject: "Verify your email", body: `${webOrigin}/verify#t=${token}` }; + case "password_reset": + return { subject: "Reset your password", body: `${webOrigin}/reset#t=${token}` }; + case "account_exists": + return { + subject: "Someone tried to sign up with your email", + body: "Someone tried to sign up with your email address — log in or reset your password.", + }; + } +} + +// Claims and processes at most one due row. Returns false when there was nothing to do. +export async function processOnce(pool: Pool, transport: MailTransport, webOrigin: string): Promise { + return withTransaction(pool, async (client) => { + const row = await claimDueOutboxRow(client); + if (row === null) return false; + + const email = await getUserEmail(client, row.user_id); + + // Minted fresh on every attempt, even a retry of a previously-attempted send (§9: if a + // send succeeded but the ack was lost, the retry mints a new token — the user may end up + // with two valid links, both expiring normally; accepted). + let token = ""; + if (row.kind === "verify_email") { + token = randomToken(); + await insertEmailVerificationToken(client, row.user_id, hashToken(token), new Date(Date.now() + VERIFY_TOKEN_TTL_MS)); + } else if (row.kind === "password_reset") { + token = randomToken(); + await insertPasswordResetToken(client, row.user_id, hashToken(token), new Date(Date.now() + RESET_TOKEN_TTL_MS)); + } + + const { subject, body } = buildMessage(row.kind, token, webOrigin); + + try { + await transport.send({ to: email, subject, body, idempotencyKey: row.id }); + await markOutboxSent(client, row.id); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + const failureNumber = row.attempts + 1; + // "after 5 failures → failed": the 5th failure gives up rather than scheduling a 6th + // attempt, so only the first 4 backoff delays (1, 5, 30, 120 min) are ever actually used. + if (failureNumber >= BACKOFF_MINUTES.length) { + await markOutboxFailed(client, row.id, message); + // ponytail: real alerting (Sentry/metrics, §11) isn't wired yet — this is the + // placeholder until that pipeline exists. + console.error(`[mailer] outbox ${row.id} failed permanently after ${failureNumber} attempts: ${message}`); + } else { + const delayMs = BACKOFF_MINUTES[failureNumber - 1] * 60 * 1000; + await rescheduleOutbox(client, row.id, new Date(Date.now() + delayMs), message); + } + } + return true; + }); +} diff --git a/apps/api/src/mailer/transport.ts b/apps/api/src/mailer/transport.ts new file mode 100644 index 0000000..4f16fe6 --- /dev/null +++ b/apps/api/src/mailer/transport.ts @@ -0,0 +1,21 @@ +// §9: sends use the provider's idempotency key = outbox id. No real provider (Postmark/SES/ +// Resend) credentials exist yet — this console transport is the dev/test stand-in; a real +// provider adapter slots in here later behind the same MailTransport interface. +export interface MailMessage { + to: string; + subject: string; + body: string; + idempotencyKey: string; +} + +export interface MailTransport { + send(message: MailMessage): Promise; +} + +export class ConsoleMailTransport implements MailTransport { + async send(message: MailMessage): Promise { + console.log( + `[mailer] to=${message.to} subject="${message.subject}" idempotencyKey=${message.idempotencyKey}\n${message.body}` + ); + } +} diff --git a/apps/api/src/rate-limit.ts b/apps/api/src/rate-limit.ts new file mode 100644 index 0000000..1091826 --- /dev/null +++ b/apps/api/src/rate-limit.ts @@ -0,0 +1,49 @@ +// Auth rate limits (§9), CGNAT-aware: a primary key (email / token hash) plus a looser +// per-IP backstop. Redis-backed fixed window; falls back to a per-instance in-memory +// window when Redis is unreachable — fail-safe (still enforced), never fail-open. +import { getRedis } from "./redis.js"; + +export interface LimitSpec { + key: string; + limit: number; + windowSeconds: number; +} + +const memoryWindows = new Map(); +let warnedRedisDown = false; + +function checkInMemory(spec: LimitSpec): boolean { + const now = Date.now(); + const existing = memoryWindows.get(spec.key); + if (existing === undefined || now >= existing.expiresAt) { + memoryWindows.set(spec.key, { count: 1, expiresAt: now + spec.windowSeconds * 1000 }); + return true; + } + existing.count += 1; + return existing.count <= spec.limit; +} + +// Returns true when the request is within the limit (and has been counted against it). +export async function checkLimit(spec: LimitSpec): Promise { + try { + const redis = getRedis(); + const fullKey = `ratelimit:${spec.key}:${spec.windowSeconds}`; + const count = await redis.incr(fullKey); + if (count === 1) await redis.expire(fullKey, spec.windowSeconds); + warnedRedisDown = false; + return count <= spec.limit; + } catch { + if (!warnedRedisDown) { + // ponytail: §11-grade alerting isn't wired yet (no Sentry/metrics pipeline) — + // this console.warn is the placeholder until that exists. + console.warn("[rate-limit] Redis unavailable, falling back to in-memory limiter"); + warnedRedisDown = true; + } + return checkInMemory(spec); + } +} + +export async function checkAllLimits(specs: LimitSpec[]): Promise { + const results = await Promise.all(specs.map(checkLimit)); + return results.every(Boolean); +} diff --git a/apps/api/src/redis.ts b/apps/api/src/redis.ts new file mode 100644 index 0000000..12f481d --- /dev/null +++ b/apps/api/src/redis.ts @@ -0,0 +1,22 @@ +// Redis (§10): anonymous page cache + auth rate-limit counters. Commands fail fast +// (no offline queue, 1 retry) rather than hang, so callers can fall back promptly. +import { Redis } from "ioredis"; + +export const REDIS_URL = process.env.REDIS_URL ?? "redis://localhost:6379"; + +let client: Redis | null = null; + +export function getRedis(): Redis { + if (client === null) { + client = new Redis(REDIS_URL, { + enableOfflineQueue: false, + maxRetriesPerRequest: 1, + retryStrategy: (times: number) => Math.min(times * 200, 2000), + lazyConnect: false, + }); + // ioredis crashes the process on an unhandled 'error' event; failures already surface + // to callers as rejected commands, so a no-op listener is enough here. + client.on("error", () => {}); + } + return client; +} diff --git a/apps/api/test/global-setup.ts b/apps/api/test/global-setup.ts new file mode 100644 index 0000000..aea95d5 --- /dev/null +++ b/apps/api/test/global-setup.ts @@ -0,0 +1,11 @@ +// Rate-limit counters live in Redis and persist across test runs (and across repeated +// `vitest run` invocations against the same fixed test emails/IP within a single run) — +// without this, a rerun inherits counts from the previous one and trips limits spuriously. +// This Redis instance is this project's own dev instance, so a full flush is safe. +import { Redis } from "ioredis"; + +export default async function setup(): Promise { + const redis = new Redis(process.env.REDIS_URL ?? "redis://localhost:6379"); + await redis.flushdb(); + await redis.quit(); +} diff --git a/apps/api/test/mailer.test.ts b/apps/api/test/mailer.test.ts new file mode 100644 index 0000000..03bd6ae --- /dev/null +++ b/apps/api/test/mailer.test.ts @@ -0,0 +1,123 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { getPool } from "../src/db.js"; +import { hashToken } from "../src/features/auth/tokens.js"; +import { processOnce } from "../src/mailer/service.js"; +import type { MailMessage, MailTransport } from "../src/mailer/transport.js"; +import { cleanupTestUser, createUser } from "./helpers.js"; + +const WEB_ORIGIN = "http://localhost:5173"; +const EMAIL = "mailer-test@mail.example"; + +class RecordingTransport implements MailTransport { + sent: MailMessage[] = []; + failNext = 0; + + async send(message: MailMessage): Promise { + if (this.failNext > 0) { + this.failNext -= 1; + throw new Error("simulated send failure"); + } + this.sent.push(message); + } +} + +beforeEach(() => cleanupTestUser(EMAIL)); +afterEach(() => cleanupTestUser(EMAIL)); + +describe("mailer processOnce", () => { + it("returns false when there is nothing due", async () => { + const transport = new RecordingTransport(); + expect(await processOnce(getPool(), transport, WEB_ORIGIN)).toBe(false); + expect(transport.sent).toEqual([]); + }); + + it("mints a verification token, sends the link, and marks the row sent", async () => { + const userId = await createUser(EMAIL, "a-strong-passphrase"); + await getPool().query("INSERT INTO email_outbox (user_id, kind) VALUES ($1, 'verify_email')", [userId]); + + const transport = new RecordingTransport(); + expect(await processOnce(getPool(), transport, WEB_ORIGIN)).toBe(true); + + expect(transport.sent).toHaveLength(1); + expect(transport.sent[0].to).toBe(EMAIL); + const token = transport.sent[0].body.split("#t=")[1]; + expect(token).toBeTruthy(); + + const tokenRow = await getPool().query( + "SELECT user_id FROM email_verification_tokens WHERE token_hash = $1", + [hashToken(token)] + ); + expect(tokenRow.rows[0]?.user_id).toBe(userId); + + const outbox = await getPool().query("SELECT status FROM email_outbox WHERE user_id = $1", [userId]); + expect(outbox.rows[0].status).toBe("sent"); + }); + + it("mints a password_reset token for that kind", async () => { + const userId = await createUser(EMAIL, "a-strong-passphrase"); + await getPool().query("INSERT INTO email_outbox (user_id, kind) VALUES ($1, 'password_reset')", [userId]); + + const transport = new RecordingTransport(); + await processOnce(getPool(), transport, WEB_ORIGIN); + + const token = transport.sent[0].body.split("#t=")[1]; + const tokenRow = await getPool().query( + "SELECT user_id FROM password_reset_tokens WHERE token_hash = $1", + [hashToken(token)] + ); + expect(tokenRow.rows[0]?.user_id).toBe(userId); + }); + + it("sends account_exists with no token minted", async () => { + const userId = await createUser(EMAIL, "a-strong-passphrase"); + await getPool().query("INSERT INTO email_outbox (user_id, kind) VALUES ($1, 'account_exists')", [userId]); + + const transport = new RecordingTransport(); + await processOnce(getPool(), transport, WEB_ORIGIN); + + expect(transport.sent[0].body).not.toContain("#t="); + }); + + it("reschedules with backoff on failure, without losing the row", async () => { + const userId = await createUser(EMAIL, "a-strong-passphrase"); + await getPool().query("INSERT INTO email_outbox (user_id, kind) VALUES ($1, 'verify_email')", [userId]); + + const transport = new RecordingTransport(); + transport.failNext = 1; + await processOnce(getPool(), transport, WEB_ORIGIN); + + const outbox = await getPool().query( + "SELECT status, attempts, next_attempt_at, last_error FROM email_outbox WHERE user_id = $1", + [userId] + ); + expect(outbox.rows[0].status).toBe("pending"); + expect(outbox.rows[0].attempts).toBe(1); + expect(outbox.rows[0].last_error).toBe("simulated send failure"); + // First backoff is 1 minute — next_attempt_at should be in the future, not immediately due. + expect(new Date(outbox.rows[0].next_attempt_at).getTime()).toBeGreaterThan(Date.now()); + + // Not due yet, so a tick right now finds nothing to do. + expect(await processOnce(getPool(), transport, WEB_ORIGIN)).toBe(false); + }); + + it("marks the row failed after exhausting all retries", async () => { + const userId = await createUser(EMAIL, "a-strong-passphrase"); + await getPool().query("INSERT INTO email_outbox (user_id, kind) VALUES ($1, 'verify_email')", [userId]); + + const transport = new RecordingTransport(); + for (let i = 0; i < 5; i++) { + transport.failNext = 1; + // Force each retry to be immediately due instead of waiting out the real backoff. + await getPool().query( + "UPDATE email_outbox SET next_attempt_at = now() WHERE user_id = $1 AND status = 'pending'", + [userId] + ); + await processOnce(getPool(), transport, WEB_ORIGIN); + } + + const outbox = await getPool().query("SELECT status, attempts FROM email_outbox WHERE user_id = $1", [userId]); + expect(outbox.rows[0].status).toBe("failed"); + expect(outbox.rows[0].attempts).toBe(5); + }); +}); diff --git a/apps/api/test/rate-limit.test.ts b/apps/api/test/rate-limit.test.ts new file mode 100644 index 0000000..d4fa484 --- /dev/null +++ b/apps/api/test/rate-limit.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; + +import { checkLimit } from "../src/rate-limit.js"; + +describe("checkLimit", () => { + it("allows up to the limit, then rejects within the window", async () => { + const spec = { key: `test-${Date.now()}`, limit: 3, windowSeconds: 60 }; + expect(await checkLimit(spec)).toBe(true); + expect(await checkLimit(spec)).toBe(true); + expect(await checkLimit(spec)).toBe(true); + expect(await checkLimit(spec)).toBe(false); + }); + + it("tracks separate keys independently", async () => { + const a = { key: `test-a-${Date.now()}`, limit: 1, windowSeconds: 60 }; + const b = { key: `test-b-${Date.now()}`, limit: 1, windowSeconds: 60 }; + expect(await checkLimit(a)).toBe(true); + expect(await checkLimit(b)).toBe(true); + expect(await checkLimit(a)).toBe(false); + }); +}); diff --git a/apps/api/test/signup.test.ts b/apps/api/test/signup.test.ts index ca3d541..8bff0d6 100644 --- a/apps/api/test/signup.test.ts +++ b/apps/api/test/signup.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { buildApp, WEB_ORIGIN } from "../src/app.js"; import { getPool } from "../src/db.js"; +import { getRedis } from "../src/redis.js"; // *.example is RFC 2606 reserved; mirrors apps/ingestion's test-fixture convention. const EMAIL = "signup-test@mail.example"; @@ -12,6 +13,14 @@ async function cleanup(): Promise { [EMAIL] ); await getPool().query("DELETE FROM users WHERE lower(email) = lower($1)", [EMAIL]); + // Tests reuse the same email across cases; the signup rate limit (3/hour, §9) is keyed on + // it too, so it needs the same kind of reset as the DB rows above. Best-effort: the very + // first call can race the client's initial connection (nothing to clean up yet anyway). + try { + await getRedis().del(`ratelimit:signup:email:${EMAIL}:3600`); + } catch { + // ignore + } } beforeEach(cleanup); @@ -84,4 +93,16 @@ describe("POST /auth/signup", () => { expect(res.json()).toEqual({ error: "common_password" }); await app.close(); }); + + it("rate-limits repeated signups for the same email (§9: 3/hour)", async () => { + const app = buildApp(); + for (let i = 0; i < 3; i++) { + const res = await post(app, { email: EMAIL, password: "a-strong-passphrase" }); + expect(res.statusCode).toBe(202); + } + const fourth = await post(app, { email: EMAIL, password: "a-strong-passphrase" }); + expect(fourth.statusCode).toBe(429); + expect(fourth.json()).toEqual({ error: "rate_limited" }); + await app.close(); + }); }); diff --git a/apps/api/vitest.config.ts b/apps/api/vitest.config.ts new file mode 100644 index 0000000..f87d3e7 --- /dev/null +++ b/apps/api/vitest.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + globalSetup: "./test/global-setup.ts", + // Tests hit a real shared dev Postgres (no mocking, matching apps/ingestion's pytest + // convention) — pytest runs files serially for the same reason. Some tables (email_outbox + // especially, since the mailer's claim query deliberately scans the whole table) aren't + // scoped per test file, so parallel file execution causes real cross-file lock + // contention and claim races, not just slower tests. + fileParallelism: false, + }, +}); diff --git a/infra/docker-compose.yml b/infra/docker-compose.yml index 329a234..fdb4b24 100644 --- a/infra/docker-compose.yml +++ b/infra/docker-compose.yml @@ -17,6 +17,16 @@ services: timeout: 5s retries: 10 + redis: + image: redis:7-alpine + ports: + - "6379:6379" + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 5s + retries: 10 + ingestion: build: ../apps/ingestion environment: @@ -32,10 +42,24 @@ services: build: ../apps/api environment: DATABASE_URL: postgresql://newstack:newstack@db:5432/newstack + REDIS_URL: redis://redis:6379 WEB_ORIGIN: http://localhost:5173 PORT: "3000" ports: - "3000:3000" + depends_on: + db: + condition: service_healthy + redis: + condition: service_healthy + restart: unless-stopped + + mailer: + build: ../apps/api + command: ["node", "dist/mailer/index.js"] + environment: + DATABASE_URL: postgresql://newstack:newstack@db:5432/newstack + WEB_ORIGIN: http://localhost:5173 depends_on: db: condition: service_healthy From fdbb911f948c52dab4fc6cda0ff929e10449e80b Mon Sep 17 00:00:00 2001 From: saran Date: Sat, 10 Oct 2026 22:50:15 +0530 Subject: [PATCH 06/12] worked on feed workflow and story handler --- apps/api/src/app.ts | 35 +++- apps/api/src/features/auth/routes.ts | 56 +++-- apps/api/src/features/blindspot/repo.ts | 79 +++++++ apps/api/src/features/blindspot/service.ts | 48 +++++ apps/api/src/features/curator/repo.ts | 82 ++++++++ apps/api/src/features/curator/routes.ts | 72 +++++++ apps/api/src/features/curator/service.ts | 111 ++++++++++ apps/api/src/features/feed/repo.ts | 89 ++++++++ apps/api/src/features/feed/routes.ts | 34 +++ apps/api/src/features/feed/service.ts | 98 +++++++++ apps/api/src/features/rating/repo.ts | 101 +++++++++ apps/api/src/features/rating/routes.ts | 53 +++++ apps/api/src/features/rating/service.ts | 124 +++++++++++ apps/api/src/features/saved/repo.ts | 51 +++++ apps/api/src/features/saved/routes.ts | 34 +++ apps/api/src/features/story/repo.ts | 74 +++++++ apps/api/src/features/story/routes.ts | 32 +++ apps/api/src/features/story/service.ts | 143 +++++++++++++ apps/api/src/features/topic/repo.ts | 66 ++++++ apps/api/src/features/topic/routes.ts | 50 +++++ apps/api/src/features/topic/service.ts | 33 +++ apps/api/src/page-cache.ts | 20 ++ apps/api/src/pagination.ts | 74 +++++++ apps/api/src/plugins/curator.ts | 15 ++ apps/api/test/app.test.ts | 25 +++ apps/api/test/blindspot.test.ts | 95 +++++++++ apps/api/test/curator.test.ts | 195 ++++++++++++++++++ apps/api/test/feed-helpers.ts | 159 ++++++++++++++ apps/api/test/feed.test.ts | 114 ++++++++++ apps/api/test/pagination.test.ts | 58 ++++++ apps/api/test/rating.test.ts | 154 ++++++++++++++ apps/api/test/redis-helpers.ts | 33 +++ apps/api/test/saved.test.ts | 114 ++++++++++ apps/api/test/signup.test.ts | 11 +- apps/api/test/story.test.ts | 109 ++++++++++ apps/api/test/topic.test.ts | 119 +++++++++++ infra/migrations/0002_blindspot_functions.sql | 29 +++ 37 files changed, 2747 insertions(+), 42 deletions(-) create mode 100644 apps/api/src/features/blindspot/repo.ts create mode 100644 apps/api/src/features/blindspot/service.ts create mode 100644 apps/api/src/features/curator/repo.ts create mode 100644 apps/api/src/features/curator/routes.ts create mode 100644 apps/api/src/features/curator/service.ts create mode 100644 apps/api/src/features/feed/repo.ts create mode 100644 apps/api/src/features/feed/routes.ts create mode 100644 apps/api/src/features/feed/service.ts create mode 100644 apps/api/src/features/rating/repo.ts create mode 100644 apps/api/src/features/rating/routes.ts create mode 100644 apps/api/src/features/rating/service.ts create mode 100644 apps/api/src/features/saved/repo.ts create mode 100644 apps/api/src/features/saved/routes.ts create mode 100644 apps/api/src/features/story/repo.ts create mode 100644 apps/api/src/features/story/routes.ts create mode 100644 apps/api/src/features/story/service.ts create mode 100644 apps/api/src/features/topic/repo.ts create mode 100644 apps/api/src/features/topic/routes.ts create mode 100644 apps/api/src/features/topic/service.ts create mode 100644 apps/api/src/page-cache.ts create mode 100644 apps/api/src/pagination.ts create mode 100644 apps/api/src/plugins/curator.ts create mode 100644 apps/api/test/blindspot.test.ts create mode 100644 apps/api/test/curator.test.ts create mode 100644 apps/api/test/feed-helpers.ts create mode 100644 apps/api/test/feed.test.ts create mode 100644 apps/api/test/pagination.test.ts create mode 100644 apps/api/test/rating.test.ts create mode 100644 apps/api/test/redis-helpers.ts create mode 100644 apps/api/test/saved.test.ts create mode 100644 apps/api/test/story.test.ts create mode 100644 apps/api/test/topic.test.ts create mode 100644 infra/migrations/0002_blindspot_functions.sql diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 8a03360..4831878 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -4,8 +4,15 @@ import Fastify, { type FastifyInstance } from "fastify"; import { WEB_ORIGIN } from "./config.js"; import { authRoutes } from "./features/auth/routes.js"; +import { curatorRoutes } from "./features/curator/routes.js"; +import { feedRoutes } from "./features/feed/routes.js"; +import { storyRoutes } from "./features/story/routes.js"; +import { ratingCuratorRoutes, ratingPublicRoutes } from "./features/rating/routes.js"; +import { savedRoutes } from "./features/saved/routes.js"; +import { topicRoutes } from "./features/topic/routes.js"; import { authPlugin } from "./plugins/auth.js"; import { cachePlugin } from "./plugins/cache.js"; +import { curatorPlugin } from "./plugins/curator.js"; export { WEB_ORIGIN } from "./config.js"; @@ -24,16 +31,19 @@ export function buildApp(): FastifyInstance { const app = Fastify({ logger: true, trustProxy: TRUSTED_PROXY }); // CSRF defense in depth (§9): SameSite=Strict cookies plus an exact-Origin check plus a - // JSON-only content type on every state-changing request. + // JSON-only content type on every state-changing request. Doesn't apply to /api/curator/*: + // CSRF exploits ambient browser credentials (cookies), and curator routes carry none — + // they're authenticated by an explicit header a cross-site page can't attach, and are + // called by scripts/tooling with no browser Origin at all. app.addHook("onRequest", async (request, reply) => { - if (!MUTATING_METHODS.has(request.method)) return; + if (!MUTATING_METHODS.has(request.method) || request.url.startsWith("/api/curator/")) return; if (request.headers.origin !== WEB_ORIGIN) { reply.code(403).send({ error: "origin_mismatch" }); return reply; } }); app.addHook("preValidation", async (request, reply) => { - if (!MUTATING_METHODS.has(request.method)) return; + if (!MUTATING_METHODS.has(request.method) || request.url.startsWith("/api/curator/")) return; const contentType = request.headers["content-type"] ?? ""; if (!contentType.startsWith("application/json")) { reply.code(415).send({ error: "unsupported_content_type" }); @@ -44,6 +54,13 @@ export function buildApp(): FastifyInstance { app.addHook("onSend", async (request, reply, payload) => { reply.header("Access-Control-Allow-Origin", WEB_ORIGIN); reply.header("Access-Control-Allow-Credentials", "true"); + // §10 cache isolation: authenticated and auth responses never cache; /api/public/* + // overrides this with its own public, max-age=30 header (cachePlugin's onSend). + if (request.url.startsWith("/auth/")) { + reply.header("Cache-Control", "no-store"); + } else if (request.url.startsWith("/api/") && !request.url.startsWith("/api/public/")) { + reply.header("Cache-Control", "private, no-store"); + } return payload; }); @@ -58,6 +75,10 @@ export function buildApp(): FastifyInstance { // keep it from applying to sibling routes registered directly on publicScope. app.register(async (publicScope) => { await cachePlugin(publicScope); + await feedRoutes(publicScope); + await storyRoutes(publicScope); + await topicRoutes(publicScope); + await ratingPublicRoutes(publicScope); }, { prefix: "/api/public" }); // /api/* — auth plugin, no cache plugin. Same direct-call reasoning as above. @@ -66,7 +87,15 @@ export function buildApp(): FastifyInstance { // Minimal authenticated echo route — first real consumer of request.userId, and gives // the auth plugin an actual registered route to run its hook against. apiScope.get("/whoami", async (request) => ({ userId: request.userId })); + await savedRoutes(apiScope); }, { prefix: "/api" }); + // /api/curator/* — shared-secret auth (curatorPlugin), no user-auth or cache plugin. + app.register(async (curatorScope) => { + await curatorPlugin(curatorScope); + await ratingCuratorRoutes(curatorScope); + await curatorRoutes(curatorScope); + }, { prefix: "/api/curator" }); + return app; } diff --git a/apps/api/src/features/auth/routes.ts b/apps/api/src/features/auth/routes.ts index 2dc3210..0f6eb37 100644 --- a/apps/api/src/features/auth/routes.ts +++ b/apps/api/src/features/auth/routes.ts @@ -22,9 +22,13 @@ const REFRESH_COOKIE_PATH = "/auth/refresh"; const REFRESH_MAX_AGE_SECONDS = 30 * 24 * 60 * 60; // Returns true when within limits (and the attempt is already counted). Sends 429 otherwise. +// Fastify async handlers must `return reply` whenever they call reply.send() themselves +// (not just return a value) — otherwise, when onSend hooks exist at more than one +// encapsulation level (e.g. a root hook plus a scoped one), Fastify double-processes the +// send and crashes with FST_ERR_REP_ALREADY_SENT. Every handler below follows that rule. async function enforceRateLimits(reply: FastifyReply, specs: LimitSpec[]): Promise { if (await checkAllLimits(specs)) return true; - reply.code(429).send({ error: "rate_limited" }); + await reply.code(429).send({ error: "rate_limited" }); return false; } @@ -50,44 +54,40 @@ export async function authRoutes(app: FastifyInstance): Promise { if (typeof body?.email === "string") { specs.push({ key: `signup:email:${body.email.toLowerCase()}`, limit: 3, windowSeconds: 3600 }); } - if (!(await enforceRateLimits(reply, specs))) return; + if (!(await enforceRateLimits(reply, specs))) return reply; try { const { email, password } = validateSignupInput(body?.email, body?.password); await signup(getPool(), email, password); } catch (err) { if (err instanceof ValidationError) { - reply.code(400).send({ error: err.code }); - return; + return reply.code(400).send({ error: err.code }); } throw err; } - reply.code(202).send({ message: "check your email" }); + return reply.code(202).send({ message: "check your email" }); }); app.post("/verify", async (request, reply) => { const body = request.body as { token?: unknown } | undefined; if (typeof body?.token !== "string") { - reply.code(400).send({ error: "invalid_or_used" }); - return; + return reply.code(400).send({ error: "invalid_or_used" }); } try { await verifyEmail(getPool(), body.token); } catch (err) { if (err instanceof ValidationError) { - reply.code(400).send({ error: err.code }); - return; + return reply.code(400).send({ error: err.code }); } throw err; } - reply.code(200).send({ status: "verified" }); + return reply.code(200).send({ status: "verified" }); }); app.post("/login", async (request, reply) => { const body = request.body as { email?: unknown; password?: unknown } | undefined; if (typeof body?.email !== "string" || typeof body?.password !== "string") { - reply.code(401).send({ error: "invalid_credentials" }); - return; + return reply.code(401).send({ error: "invalid_credentials" }); } const email = body.email.toLowerCase(); const specs: LimitSpec[] = [ @@ -95,16 +95,15 @@ export async function authRoutes(app: FastifyInstance): Promise { { key: `login:email:${email}:min`, limit: 5, windowSeconds: 60 }, { key: `login:email:${email}:hour`, limit: 20, windowSeconds: 3600 }, ]; - if (!(await enforceRateLimits(reply, specs))) return; + if (!(await enforceRateLimits(reply, specs))) return reply; try { const result = await login(getPool(), body.email, body.password); setRefreshCookie(reply, result.refreshToken); - reply.code(200).send({ accessToken: result.accessToken }); + return reply.code(200).send({ accessToken: result.accessToken }); } catch (err) { if (err instanceof AuthError) { - reply.code(401).send({ error: err.code }); - return; + return reply.code(401).send({ error: err.code }); } throw err; } @@ -113,24 +112,22 @@ export async function authRoutes(app: FastifyInstance): Promise { app.post("/refresh", async (request, reply) => { const presented = readCookie(request.headers.cookie, REFRESH_COOKIE); if (presented === undefined) { - reply.code(401).send({ error: "refresh_rejected" }); - return; + return reply.code(401).send({ error: "refresh_rejected" }); } const specs: LimitSpec[] = [ ipSpec(request, "refresh", 600, 60), { key: `refresh:token:${hashToken(presented)}`, limit: 10, windowSeconds: 60 }, ]; - if (!(await enforceRateLimits(reply, specs))) return; + if (!(await enforceRateLimits(reply, specs))) return reply; try { const result = await refreshSession(getPool(), presented); setRefreshCookie(reply, result.refreshToken); - reply.code(200).send({ accessToken: result.accessToken }); + return reply.code(200).send({ accessToken: result.accessToken }); } catch (err) { if (err instanceof AuthError) { clearRefreshCookie(reply); - reply.code(401).send({ error: err.code }); - return; + return reply.code(401).send({ error: err.code }); } throw err; } @@ -139,35 +136,32 @@ export async function authRoutes(app: FastifyInstance): Promise { app.post("/reset/request", async (request, reply) => { const body = request.body as { email?: unknown } | undefined; if (typeof body?.email !== "string") { - reply.code(400).send({ error: "invalid_email" }); - return; + return reply.code(400).send({ error: "invalid_email" }); } const specs: LimitSpec[] = [ ipSpec(request, "reset-request", 120, 60), { key: `reset-request:email:${body.email.toLowerCase()}`, limit: 3, windowSeconds: 3600 }, ]; - if (!(await enforceRateLimits(reply, specs))) return; + if (!(await enforceRateLimits(reply, specs))) return reply; await requestPasswordReset(getPool(), body.email); - reply.code(202).send({ message: "check your email" }); + return reply.code(202).send({ message: "check your email" }); }); app.post("/reset/confirm", async (request, reply) => { const body = request.body as { token?: unknown; newPassword?: unknown } | undefined; if (typeof body?.token !== "string") { - reply.code(400).send({ error: "invalid_or_used" }); - return; + return reply.code(400).send({ error: "invalid_or_used" }); } try { const newPassword = validateNewPassword(body.newPassword); await confirmPasswordReset(getPool(), body.token, newPassword); } catch (err) { if (err instanceof ValidationError) { - reply.code(400).send({ error: err.code }); - return; + return reply.code(400).send({ error: err.code }); } throw err; } - reply.code(200).send({ status: "reset" }); + return reply.code(200).send({ status: "reset" }); }); } diff --git a/apps/api/src/features/blindspot/repo.ts b/apps/api/src/features/blindspot/repo.ts new file mode 100644 index 0000000..fc37a67 --- /dev/null +++ b/apps/api/src/features/blindspot/repo.ts @@ -0,0 +1,79 @@ +import type { Pool } from "pg"; + +export interface Baseline { + p_left: number; + p_center: number; + p_right: number; +} + +export async function fetchLatestBaseline(pool: Pool, spectrum: string, asOf: Date): Promise { + const result = await pool.query( + `SELECT p_left, p_center, p_right FROM bias_baselines + WHERE spectrum = $1 AND computed_at <= $2 + ORDER BY computed_at DESC LIMIT 1`, + [spectrum, asOf] + ); + return result.rows[0] ?? null; +} + +export interface BlindspotRow { + id: string; + title: string; + last_coverage_at: Date; + side: "L" | "R"; + k_left: number; + k_center: number; + k_right: number; +} + +// PRODUCT §4.6, verbatim (cursor params bound the same way every other feed in this codebase +// binds them): stories active in the last 7 days, newest coverage first. +export async function fetchBlindspotPage( + pool: Pool, + args: { asOf: Date; cursorCoverageAt: Date | null; cursorId: string | null; limit: number } +): Promise { + const result = await pool.query<{ + id: string; + title: string; + last_coverage_at: Date; + side: "L" | "R"; + k_left: string; + k_center: string; + k_right: string; + }>( + `WITH base AS ( + SELECT p_left, p_center, p_right FROM bias_baselines + WHERE spectrum = 'IN' AND computed_at <= $1 + ORDER BY computed_at DESC LIMIT 1), + counts AS ( + SELECT a.story_id, + count(DISTINCT a.source_id) FILTER (WHERE r.bias_rating IN ('left','center-left')) AS k_left, + count(DISTINCT a.source_id) FILTER (WHERE r.bias_rating = 'center') AS k_center, + count(DISTINCT a.source_id) FILTER (WHERE r.bias_rating IN ('center-right','right')) AS k_right + FROM articles a + JOIN source_ratings r ON r.source_id = a.source_id AND r.superseded_at IS NULL + WHERE a.hidden_at IS NULL AND r.spectrum = 'IN' AND r.confidence <> 'low' + AND a.story_id IN (SELECT id FROM stories + WHERE status = 'active' AND hidden_at IS NULL + AND last_coverage_at <= $1 + AND last_coverage_at > $1 - interval '7 days') + GROUP BY a.story_id), + flagged AS ( + SELECT c.*, blindspot_side(c.k_left::int, c.k_center::int, c.k_right::int, + b.p_left, b.p_center, b.p_right) AS side + FROM counts c CROSS JOIN base b) + SELECT s.id, s.title, s.last_coverage_at, f.side, f.k_left, f.k_center, f.k_right + FROM flagged f JOIN stories s ON s.id = f.story_id + WHERE f.side IS NOT NULL + AND ($2::timestamptz IS NULL OR (s.last_coverage_at, s.id) < ($2, $3)) + ORDER BY s.last_coverage_at DESC, s.id DESC + LIMIT $4`, + [args.asOf, args.cursorCoverageAt, args.cursorId, args.limit] + ); + return result.rows.map((r) => ({ + ...r, + k_left: Number(r.k_left), + k_center: Number(r.k_center), + k_right: Number(r.k_right), + })); +} diff --git a/apps/api/src/features/blindspot/service.ts b/apps/api/src/features/blindspot/service.ts new file mode 100644 index 0000000..cc9816f --- /dev/null +++ b/apps/api/src/features/blindspot/service.ts @@ -0,0 +1,48 @@ +import type { Pool } from "pg"; + +import { buildPage, decodeCursor } from "../../pagination.js"; +import { fetchBlindspotPage, type BlindspotRow } from "./repo.js"; + +const PAGE_SIZE = 20; +const FEED_NAME = "blindspot"; + +export interface BlindspotItem { + id: string; + title: string; + lastCoverageAt: string; + side: "L" | "R"; + countedLeft: number; + countedCenter: number; + countedRight: number; +} + +export interface BlindspotResponse { + items: BlindspotItem[]; + asOf: string; + nextCursor: string | null; +} + +// PRODUCT §4.9: not registered as a public route — no calibration round has run yet, so no +// blindspot flag may be shown publicly. This is the mechanism, built dormant, same pattern as +// every other "designed now, built when its trigger fires" item in the complexity budget. +export async function getBlindspotFeed(pool: Pool, cursorRaw: string | undefined): Promise { + const cursor = cursorRaw === undefined ? null : decodeCursor(cursorRaw, FEED_NAME); + const asOf = cursor === null ? new Date() : new Date(cursor.as_of); + const cursorCoverageAt = cursor === null ? null : new Date(cursor.c); + const cursorId = cursor === null ? null : cursor.id; + + const rows = await fetchBlindspotPage(pool, { asOf, cursorCoverageAt, cursorId, limit: PAGE_SIZE + 1 }); + const page = buildPage(rows, PAGE_SIZE, FEED_NAME, asOf); + + const items: BlindspotItem[] = page.items.map((row: BlindspotRow) => ({ + id: row.id, + title: row.title, + lastCoverageAt: row.last_coverage_at.toISOString(), + side: row.side, + countedLeft: row.k_left, + countedCenter: row.k_center, + countedRight: row.k_right, + })); + + return { items, asOf: page.as_of, nextCursor: page.next_cursor }; +} diff --git a/apps/api/src/features/curator/repo.ts b/apps/api/src/features/curator/repo.ts new file mode 100644 index 0000000..a20696b --- /dev/null +++ b/apps/api/src/features/curator/repo.ts @@ -0,0 +1,82 @@ +import type { Pool, PoolClient } from "pg"; + +export interface NewSourceInput { + name: string; + domain: string; + countryCode: string; + coverageRegion: string | null; + language: string; + feedTimezone: string; + adapter: string | null; + rssUrl: string | null; + logoUrl: string | null; +} + +export async function insertSource(pool: Pool, input: NewSourceInput): Promise<{ id: string }> { + const result = await pool.query<{ id: string }>( + `INSERT INTO sources (name, domain, country_code, coverage_region, language, feed_timezone, adapter, rss_url, logo_url) + VALUES ($1, $2, $3, $4, $5, $6, coalesce($7, 'generic'), $8, $9) + RETURNING id`, + [ + input.name, + input.domain, + input.countryCode, + input.coverageRegion, + input.language, + input.feedTimezone, + input.adapter, + input.rssUrl, + input.logoUrl, + ] + ); + return result.rows[0]; +} + +export async function insertTopic(pool: Pool, name: string, keywords: string[], createdBy: string): Promise<{ id: string }> { + const result = await pool.query<{ id: string }>( + "INSERT INTO topics (name, keywords, created_by) VALUES ($1, $2, $3) RETURNING id", + [name, keywords, createdBy] + ); + return result.rows[0]; +} + +export async function hideArticle(pool: Pool, articleId: string, reason: string): Promise { + const result = await pool.query( + "UPDATE articles SET hidden_at = now(), hidden_reason = $2 WHERE id = $1 AND hidden_at IS NULL", + [articleId, reason] + ); + return result.rowCount !== null && result.rowCount > 0; +} + +export async function hideStory(pool: Pool, storyId: string): Promise { + const result = await pool.query( + "UPDATE stories SET hidden_at = now() WHERE id = $1 AND status = 'active' AND hidden_at IS NULL", + [storyId] + ); + return result.rowCount !== null && result.rowCount > 0; +} + +export interface ClusterReportInput { + kind: "wrong_merge" | "should_merge"; + storyId: string; + articleId: string | null; + otherStoryId: string | null; + clusterVersion: string; + reportedBy: string; +} + +export async function insertClusterReport(pool: Pool, input: ClusterReportInput): Promise<{ id: string }> { + const result = await pool.query<{ id: string }>( + `INSERT INTO cluster_reports (kind, story_id, article_id, other_story_id, cluster_version, reported_by) + VALUES ($1, $2, $3, $4, $5, $6) RETURNING id`, + [input.kind, input.storyId, input.articleId, input.otherStoryId, input.clusterVersion, input.reportedBy] + ); + return result.rows[0]; +} + +export async function fetchStoryClusterVersion(pool: Pool | PoolClient, storyId: string): Promise { + const result = await pool.query<{ cluster_version: string }>("SELECT cluster_version FROM stories WHERE id = $1", [ + storyId, + ]); + return result.rows[0]?.cluster_version ?? null; +} diff --git a/apps/api/src/features/curator/routes.ts b/apps/api/src/features/curator/routes.ts new file mode 100644 index 0000000..810eab2 --- /dev/null +++ b/apps/api/src/features/curator/routes.ts @@ -0,0 +1,72 @@ +import type { FastifyInstance } from "fastify"; + +import { getPool } from "../../db.js"; +import { + createTopic, + NotFoundError, + onboardSource, + reportClusterError, + takedownArticle, + takedownStory, + validateNewSource, + ValidationError, +} from "./service.js"; + +export async function curatorRoutes(app: FastifyInstance): Promise { + app.post("/sources", async (request, reply) => { + try { + const input = validateNewSource(request.body); + const { id } = await onboardSource(getPool(), input); + return reply.code(201).send({ id }); + } catch (err) { + if (err instanceof ValidationError) return reply.code(400).send({ error: err.code }); + throw err; + } + }); + + app.post("/topics", async (request, reply) => { + const body = request.body as { name?: unknown; keywords?: unknown; createdBy?: unknown } | undefined; + try { + const { id } = await createTopic(getPool(), body?.name, body?.keywords, body?.createdBy); + return reply.code(201).send({ id }); + } catch (err) { + if (err instanceof ValidationError) return reply.code(400).send({ error: err.code }); + throw err; + } + }); + + app.post("/articles/:id/hide", async (request, reply) => { + const { id } = request.params as { id: string }; + const body = request.body as { reason?: unknown } | undefined; + try { + await takedownArticle(getPool(), id, body?.reason); + return reply.code(204).send(); + } catch (err) { + if (err instanceof ValidationError) return reply.code(400).send({ error: err.code }); + if (err instanceof NotFoundError) return reply.code(404).send({ error: "not_found" }); + throw err; + } + }); + + app.post("/stories/:id/hide", async (request, reply) => { + const { id } = request.params as { id: string }; + try { + await takedownStory(getPool(), id); + return reply.code(204).send(); + } catch (err) { + if (err instanceof NotFoundError) return reply.code(404).send({ error: "not_found" }); + throw err; + } + }); + + app.post("/cluster-reports", async (request, reply) => { + try { + const { id } = await reportClusterError(getPool(), request.body); + return reply.code(201).send({ id }); + } catch (err) { + if (err instanceof ValidationError) return reply.code(400).send({ error: err.code }); + if (err instanceof NotFoundError) return reply.code(404).send({ error: "not_found" }); + throw err; + } + }); +} diff --git a/apps/api/src/features/curator/service.ts b/apps/api/src/features/curator/service.ts new file mode 100644 index 0000000..1b6db4c --- /dev/null +++ b/apps/api/src/features/curator/service.ts @@ -0,0 +1,111 @@ +import type { Pool } from "pg"; + +import { + fetchStoryClusterVersion, + hideArticle, + hideStory, + insertClusterReport, + insertSource, + insertTopic, + type ClusterReportInput, + type NewSourceInput, +} from "./repo.js"; + +export class ValidationError extends Error { + constructor(public code: string) { + super(code); + } +} +export class NotFoundError extends Error {} + +const HIDDEN_REASONS = new Set(["takedown", "spam", "duplicate", "canonical_duplicate"]); +// Latin letters/marks (incl. accented), digits, space, and the punctuation the §10 examples +// use ("C++", "COVID-19"). \p{Script=Latin} covers the script restriction itself. +const KEYWORD_PATTERN = /^[\p{Script=Latin}\p{Mark}0-9][\p{Script=Latin}\p{Mark}0-9 +\-.&]*$/u; + +export function validateNewSource(body: unknown): NewSourceInput { + const b = (body ?? {}) as Record; + for (const field of ["name", "domain", "countryCode", "language", "feedTimezone"]) { + if (typeof b[field] !== "string" || (b[field] as string).length === 0) { + throw new ValidationError(`invalid_${field}`); + } + } + return { + name: b.name as string, + domain: b.domain as string, + countryCode: b.countryCode as string, + coverageRegion: typeof b.coverageRegion === "string" ? b.coverageRegion : null, + language: b.language as string, + feedTimezone: b.feedTimezone as string, + adapter: typeof b.adapter === "string" ? b.adapter : null, + rssUrl: typeof b.rssUrl === "string" ? b.rssUrl : null, + logoUrl: typeof b.logoUrl === "string" ? b.logoUrl : null, + }; +} + +export async function onboardSource(pool: Pool, input: NewSourceInput): Promise<{ id: string }> { + return insertSource(pool, input); +} + +// §10 topic matching, "on write": casefold+NFC, trim, collapse whitespace, drop empty/dupes, +// 2-64 chars each, 1-20 keywords, Latin script only. +export function normalizeKeywords(raw: unknown): string[] { + if (!Array.isArray(raw)) throw new ValidationError("invalid_keywords"); + const seen = new Set(); + const normalized: string[] = []; + for (const entry of raw) { + if (typeof entry !== "string") throw new ValidationError("invalid_keyword"); + const k = entry.normalize("NFC").toLowerCase().trim().replace(/\s+/g, " "); + if (k.length === 0) continue; // drop empties + if (k.length < 2 || k.length > 64) throw new ValidationError("invalid_keyword_length"); + if (!KEYWORD_PATTERN.test(k)) throw new ValidationError("invalid_keyword_script"); + if (seen.has(k)) continue; // drop duplicates + seen.add(k); + normalized.push(k); + } + if (normalized.length < 1 || normalized.length > 20) throw new ValidationError("invalid_keyword_count"); + return normalized; +} + +export async function createTopic(pool: Pool, name: unknown, keywordsRaw: unknown, createdBy: unknown): Promise<{ id: string }> { + if (typeof name !== "string" || name.length === 0) throw new ValidationError("invalid_name"); + if (typeof createdBy !== "string" || createdBy.length === 0) throw new ValidationError("invalid_created_by"); + const keywords = normalizeKeywords(keywordsRaw); + return insertTopic(pool, name, keywords, createdBy); +} + +export async function takedownArticle(pool: Pool, articleId: string, reason: unknown): Promise { + if (typeof reason !== "string" || !HIDDEN_REASONS.has(reason)) throw new ValidationError("invalid_reason"); + if (!(await hideArticle(pool, articleId, reason))) throw new NotFoundError(); +} + +export async function takedownStory(pool: Pool, storyId: string): Promise { + if (!(await hideStory(pool, storyId))) throw new NotFoundError(); +} + +export async function reportClusterError( + pool: Pool, + body: unknown +): Promise<{ id: string }> { + const b = (body ?? {}) as Record; + if (b.kind !== "wrong_merge" && b.kind !== "should_merge") throw new ValidationError("invalid_kind"); + if (typeof b.storyId !== "string") throw new ValidationError("invalid_story_id"); + if (typeof b.reportedBy !== "string" || b.reportedBy.length === 0) throw new ValidationError("invalid_reported_by"); + if (b.kind === "wrong_merge" && typeof b.articleId !== "string") throw new ValidationError("wrong_merge_needs_article_id"); + if (b.kind === "should_merge" && typeof b.otherStoryId !== "string") { + throw new ValidationError("should_merge_needs_other_story_id"); + } + + const clusterVersion = await fetchStoryClusterVersion(pool, b.storyId); + if (clusterVersion === null) throw new NotFoundError(); + + const input: ClusterReportInput = { + kind: b.kind, + storyId: b.storyId, + articleId: typeof b.articleId === "string" ? b.articleId : null, + otherStoryId: typeof b.otherStoryId === "string" ? b.otherStoryId : null, + clusterVersion, + reportedBy: b.reportedBy, + }; + return insertClusterReport(pool, input); +} diff --git a/apps/api/src/features/feed/repo.ts b/apps/api/src/features/feed/repo.ts new file mode 100644 index 0000000..8ff8c80 --- /dev/null +++ b/apps/api/src/features/feed/repo.ts @@ -0,0 +1,89 @@ +import type { Pool } from "pg"; + +export interface StoryRow { + id: string; + title: string; + summary: string | null; + last_coverage_at: Date; +} + +// `limit` should be pageSize + 1 (one extra row to detect "more pages" — see buildPage). +export async function fetchFeedPage( + pool: Pool, + args: { asOf: Date; cursorCoverageAt: Date | null; cursorId: string | null; limit: number } +): Promise { + const result = await pool.query( + `SELECT id, title, summary, last_coverage_at FROM stories + WHERE status = 'active' AND hidden_at IS NULL + AND last_coverage_at <= $1 + AND ($2::timestamptz IS NULL OR (last_coverage_at, id) < ($2, $3)) + ORDER BY last_coverage_at DESC, id DESC + LIMIT $4`, + [args.asOf, args.cursorCoverageAt, args.cursorId, args.limit] + ); + return result.rows; +} + +export async function countUpdatedSince(pool: Pool, asOf: Date): Promise { + const result = await pool.query<{ count: string }>( + `SELECT count(*) FROM stories WHERE status = 'active' AND hidden_at IS NULL AND last_coverage_at > $1`, + [asOf] + ); + return Number(result.rows[0].count); +} + +export type BiasCategory = "left" | "center-left" | "center" | "center-right" | "right" | "not_rated"; + +export interface BiasBarRow { + story_id: string; + category: BiasCategory; + total: number; + low_confidence: number; +} + +// PRODUCT §3.3: category = coalesce(bias_rating, 'not_rated'), restricted to the IN spectrum +// — a factuality-only (international) source's rating doesn't match that filter, so it +// coalesces into 'not_rated' too, exactly as §3.2 specifies ("sit outside the bar"). +export async function fetchBiasBar(pool: Pool, storyIds: string[]): Promise { + if (storyIds.length === 0) return []; + const result = await pool.query<{ story_id: string; category: BiasCategory; total: string; low_confidence: string }>( + `SELECT a.story_id, + coalesce(r.bias_rating, 'not_rated') AS category, + count(DISTINCT a.source_id) AS total, + count(DISTINCT a.source_id) FILTER (WHERE r.confidence = 'low') AS low_confidence + FROM articles a + LEFT JOIN source_ratings r + ON r.source_id = a.source_id AND r.superseded_at IS NULL AND r.spectrum = 'IN' + WHERE a.hidden_at IS NULL AND a.story_id = ANY($1) + GROUP BY a.story_id, coalesce(r.bias_rating, 'not_rated')`, + [storyIds] + ); + return result.rows.map((r) => ({ ...r, total: Number(r.total), low_confidence: Number(r.low_confidence) })); +} + +export interface RatedCountsRow { + story_id: string; + rated_source_count: number; + unrated_source_count: number; +} + +// §10 API semantics: rated/unrated by "has any current rating at all" — unlike the bar's +// spectrum filter above, a factuality-only source DOES count as rated here. +export async function fetchRatedCounts(pool: Pool, storyIds: string[]): Promise { + if (storyIds.length === 0) return []; + const result = await pool.query<{ story_id: string; rated_source_count: string; unrated_source_count: string }>( + `SELECT a.story_id, + count(DISTINCT a.source_id) FILTER (WHERE r.source_id IS NOT NULL) AS rated_source_count, + count(DISTINCT a.source_id) FILTER (WHERE r.source_id IS NULL) AS unrated_source_count + FROM articles a + LEFT JOIN source_ratings r ON r.source_id = a.source_id AND r.superseded_at IS NULL + WHERE a.hidden_at IS NULL AND a.story_id = ANY($1) + GROUP BY a.story_id`, + [storyIds] + ); + return result.rows.map((r) => ({ + story_id: r.story_id, + rated_source_count: Number(r.rated_source_count), + unrated_source_count: Number(r.unrated_source_count), + })); +} diff --git a/apps/api/src/features/feed/routes.ts b/apps/api/src/features/feed/routes.ts new file mode 100644 index 0000000..6f44057 --- /dev/null +++ b/apps/api/src/features/feed/routes.ts @@ -0,0 +1,34 @@ +import type { FastifyInstance } from "fastify"; + +import { getPool } from "../../db.js"; +import { getCachedPage, setCachedPage } from "../../page-cache.js"; +import { InvalidCursorError } from "../../pagination.js"; +import { getHomeFeed } from "./service.js"; + +const CACHE_TTL_SECONDS = 60; + +export async function feedRoutes(app: FastifyInstance): Promise { + app.get("/feed", async (request, reply) => { + const cursor = (request.query as { cursor?: unknown }).cursor; + const cursorRaw = typeof cursor === "string" ? cursor : undefined; + + const cacheKey = cursorRaw === undefined ? "page:feed:home:first" : `page:feed:home:cursor:${cursorRaw}`; + const cached = await getCachedPage(cacheKey); + if (cached !== null) { + return reply.send(JSON.parse(cached)); + } + + let result; + try { + result = await getHomeFeed(getPool(), cursorRaw); + } catch (err) { + if (err instanceof InvalidCursorError) { + return reply.code(400).send({ error: "invalid_cursor" }); + } + throw err; + } + + await setCachedPage(cacheKey, JSON.stringify(result), CACHE_TTL_SECONDS); + return reply.send(result); + }); +} diff --git a/apps/api/src/features/feed/service.ts b/apps/api/src/features/feed/service.ts new file mode 100644 index 0000000..b30dda1 --- /dev/null +++ b/apps/api/src/features/feed/service.ts @@ -0,0 +1,98 @@ +import type { Pool } from "pg"; + +import { buildPage, decodeCursor, type Page } from "../../pagination.js"; +import { type BiasBarRow, countUpdatedSince, fetchBiasBar, fetchFeedPage, fetchRatedCounts, type StoryRow } from "./repo.js"; + +const PAGE_SIZE = 20; +const FEED_NAME = "home"; + +export interface BiasBarSegment { + count: number; + lowConfidenceCount: number; +} + +export interface BiasBar { + left: BiasBarSegment; + centerLeft: BiasBarSegment; + center: BiasBarSegment; + centerRight: BiasBarSegment; + right: BiasBarSegment; + notRated: BiasBarSegment; +} + +export interface FeedItem { + id: string; + title: string; + summary: string | null; + lastCoverageAt: string; + ratedSourceCount: number; + unratedSourceCount: number; + biasBar: BiasBar; +} + +export interface FeedResponse { + items: FeedItem[]; + asOf: string; + nextCursor: string | null; + updatedCount: number; +} + +const EMPTY_SEGMENT: BiasBarSegment = { count: 0, lowConfidenceCount: 0 }; +const CATEGORY_KEY: Record = { + left: "left", + "center-left": "centerLeft", + center: "center", + "center-right": "centerRight", + right: "right", + not_rated: "notRated", +}; + +function emptyBiasBar(): BiasBar { + return { + left: { ...EMPTY_SEGMENT }, + centerLeft: { ...EMPTY_SEGMENT }, + center: { ...EMPTY_SEGMENT }, + centerRight: { ...EMPTY_SEGMENT }, + right: { ...EMPTY_SEGMENT }, + notRated: { ...EMPTY_SEGMENT }, + }; +} + +// Shared by any feed whose rows are {id, title, summary, last_coverage_at} stories — the +// home feed and the topic feed both attach the identical bias-bar/rated-count shape. +export async function buildFeedItems(pool: Pool, storyRows: StoryRow[]): Promise { + const storyIds = storyRows.map((s) => s.id); + const [biasRows, ratedRows] = await Promise.all([fetchBiasBar(pool, storyIds), fetchRatedCounts(pool, storyIds)]); + + const biasByStory = new Map(); + for (const row of biasRows) { + const bar = biasByStory.get(row.story_id) ?? emptyBiasBar(); + bar[CATEGORY_KEY[row.category]] = { count: row.total, lowConfidenceCount: row.low_confidence }; + biasByStory.set(row.story_id, bar); + } + const ratedByStory = new Map(ratedRows.map((r) => [r.story_id, r])); + + return storyRows.map((story) => ({ + id: story.id, + title: story.title, + summary: story.summary, + lastCoverageAt: story.last_coverage_at.toISOString(), + ratedSourceCount: ratedByStory.get(story.id)?.rated_source_count ?? 0, + unratedSourceCount: ratedByStory.get(story.id)?.unrated_source_count ?? 0, + biasBar: biasByStory.get(story.id) ?? emptyBiasBar(), + })); +} + +export async function getHomeFeed(pool: Pool, cursorRaw: string | undefined): Promise { + const cursor = cursorRaw === undefined ? null : decodeCursor(cursorRaw, FEED_NAME); + const asOf = cursor === null ? new Date() : new Date(cursor.as_of); + const cursorCoverageAt = cursor === null ? null : new Date(cursor.c); + const cursorId = cursor === null ? null : cursor.id; + + const rows = await fetchFeedPage(pool, { asOf, cursorCoverageAt, cursorId, limit: PAGE_SIZE + 1 }); + const page: Page = buildPage(rows, PAGE_SIZE, FEED_NAME, asOf); + + const [items, updatedCount] = await Promise.all([buildFeedItems(pool, page.items), countUpdatedSince(pool, asOf)]); + + return { items, asOf: page.as_of, nextCursor: page.next_cursor, updatedCount }; +} diff --git a/apps/api/src/features/rating/repo.ts b/apps/api/src/features/rating/repo.ts new file mode 100644 index 0000000..8d50036 --- /dev/null +++ b/apps/api/src/features/rating/repo.ts @@ -0,0 +1,101 @@ +import type { Pool, PoolClient } from "pg"; + +export interface RatingRow { + id: string; + source_id: string; + spectrum: string | null; + bias_rating: string | null; + factuality: "high" | "mixed" | "low"; + confidence: "high" | "medium" | "low" | null; + method: string; + method_version: string | null; + rated_by: string; + second_rater: string | null; + rubric_scores: unknown; + evidence: unknown; + review_due_at: Date | null; + source_ref: string | null; + notes: string | null; + rated_at: Date; + supersedes: string | null; + superseded_at: Date | null; +} + +const RATING_COLUMNS = `id, source_id, spectrum, bias_rating, factuality, confidence, method, method_version, + rated_by, second_rater, rubric_scores, evidence, review_due_at, source_ref, notes, + rated_at, supersedes, superseded_at`; + +export async function sourceExists(pool: Pool, sourceId: string): Promise { + const result = await pool.query("SELECT 1 FROM sources WHERE id = $1", [sourceId]); + return result.rowCount !== null && result.rowCount > 0; +} + +export async function fetchCurrentRating(pool: Pool, sourceId: string): Promise { + const result = await pool.query( + `SELECT ${RATING_COLUMNS} FROM source_ratings WHERE source_id = $1 AND superseded_at IS NULL`, + [sourceId] + ); + return result.rows[0] ?? null; +} + +export async function fetchRatingHistory(pool: Pool, sourceId: string): Promise { + const result = await pool.query( + `SELECT ${RATING_COLUMNS} FROM source_ratings WHERE source_id = $1 ORDER BY rated_at DESC`, + [sourceId] + ); + return result.rows; +} + +export interface NewRatingInput { + spectrum: string | null; + biasRating: string | null; + factuality: "high" | "mixed" | "low"; + confidence: "high" | "medium" | "low" | null; + method: string; + methodVersion: string | null; + ratedBy: string; + secondRater: string | null; + rubricScores: unknown; + evidence: unknown; + reviewDueAt: Date | null; + sourceRef: string | null; + notes: string | null; +} + +// §10 rating revision (atomic): the old row leaves the "current" set before the new row +// enters it, inside one transaction with the source row locked, so the partial unique index +// (idx_source_ratings_one_current) never sees two current rows for the same source. +export async function reviseRating(client: PoolClient, sourceId: string, input: NewRatingInput): Promise { + await client.query("SELECT 1 FROM sources WHERE id = $1 FOR UPDATE", [sourceId]); + const old = await client.query<{ id: string }>( + "UPDATE source_ratings SET superseded_at = now() WHERE source_id = $1 AND superseded_at IS NULL RETURNING id", + [sourceId] + ); + const oldId = old.rows[0]?.id ?? null; + + const inserted = await client.query( + `INSERT INTO source_ratings ( + source_id, spectrum, bias_rating, factuality, confidence, method, method_version, + rated_by, second_rater, rubric_scores, evidence, review_due_at, source_ref, notes, supersedes + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) + RETURNING ${RATING_COLUMNS}`, + [ + sourceId, + input.spectrum, + input.biasRating, + input.factuality, + input.confidence, + input.method, + input.methodVersion, + input.ratedBy, + input.secondRater, + input.rubricScores === undefined ? null : JSON.stringify(input.rubricScores), + input.evidence === undefined ? null : JSON.stringify(input.evidence), + input.reviewDueAt, + input.sourceRef, + input.notes, + oldId, + ] + ); + return inserted.rows[0]; +} diff --git a/apps/api/src/features/rating/routes.ts b/apps/api/src/features/rating/routes.ts new file mode 100644 index 0000000..9de15fd --- /dev/null +++ b/apps/api/src/features/rating/routes.ts @@ -0,0 +1,53 @@ +import type { FastifyInstance } from "fastify"; + +import { getPool } from "../../db.js"; +import { getCachedPage, setCachedPage } from "../../page-cache.js"; +import { getSourceRatings, SourceNotFoundError, submitRating, validateNewRating, ValidationError } from "./service.js"; + +const CACHE_TTL_SECONDS = 60; + +// Public read side: current rating + history (§10 core screen 4, source directory). +export async function ratingPublicRoutes(app: FastifyInstance): Promise { + app.get("/sources/:id/ratings", async (request, reply) => { + const { id } = request.params as { id: string }; + const cacheKey = `page:source-ratings:${id}`; + + const cached = await getCachedPage(cacheKey); + if (cached !== null) { + return reply.send(JSON.parse(cached)); + } + + let result; + try { + result = await getSourceRatings(getPool(), id); + } catch (err) { + if (err instanceof SourceNotFoundError) { + return reply.code(404).send({ error: "not_found" }); + } + throw err; + } + + await setCachedPage(cacheKey, JSON.stringify(result), CACHE_TTL_SECONDS); + return reply.send(result); + }); +} + +// Curator write side: atomic rating revision (§10). +export async function ratingCuratorRoutes(app: FastifyInstance): Promise { + app.post("/sources/:id/ratings", async (request, reply) => { + const { id } = request.params as { id: string }; + try { + const input = validateNewRating(request.body); + const result = await submitRating(getPool(), id, input); + return reply.code(201).send(result); + } catch (err) { + if (err instanceof ValidationError) { + return reply.code(400).send({ error: err.code }); + } + if (err instanceof SourceNotFoundError) { + return reply.code(404).send({ error: "not_found" }); + } + throw err; + } + }); +} diff --git a/apps/api/src/features/rating/service.ts b/apps/api/src/features/rating/service.ts new file mode 100644 index 0000000..3de6800 --- /dev/null +++ b/apps/api/src/features/rating/service.ts @@ -0,0 +1,124 @@ +import type { Pool } from "pg"; + +import { withTransaction } from "../../db.js"; +import { + fetchCurrentRating, + fetchRatingHistory, + reviseRating, + sourceExists, + type NewRatingInput, + type RatingRow, +} from "./repo.js"; + +export class SourceNotFoundError extends Error {} +export class ValidationError extends Error { + constructor(public code: string) { + super(code); + } +} + +const BIAS_RATINGS = new Set(["left", "center-left", "center", "center-right", "right"]); +const FACTUALITY_VALUES = new Set(["high", "mixed", "low"]); +const CONFIDENCE_VALUES = new Set(["high", "medium", "low"]); + +export interface RatingResponse { + id: string; + sourceId: string; + spectrum: string | null; + biasRating: string | null; + factuality: string; + confidence: string | null; + method: string; + ratedBy: string; + ratedAt: string; + supersedes: string | null; + supersededAt: string | null; +} + +function toResponse(row: RatingRow): RatingResponse { + return { + id: row.id, + sourceId: row.source_id, + spectrum: row.spectrum, + biasRating: row.bias_rating, + factuality: row.factuality, + confidence: row.confidence, + method: row.method, + ratedBy: row.rated_by, + ratedAt: row.rated_at.toISOString(), + supersedes: row.supersedes, + supersededAt: row.superseded_at?.toISOString() ?? null, + }; +} + +export async function getSourceRatings( + pool: Pool, + sourceId: string +): Promise<{ current: RatingResponse | null; history: RatingResponse[] }> { + if (!(await sourceExists(pool, sourceId))) throw new SourceNotFoundError(); + const [current, history] = await Promise.all([ + fetchCurrentRating(pool, sourceId), + fetchRatingHistory(pool, sourceId), + ]); + return { + current: current === null ? null : toResponse(current), + history: history.map(toResponse), + }; +} + +// §4 schema CHECKs, validated here first so a bad submission gets a clean 400 instead of a +// raw Postgres constraint error. +export function validateNewRating(body: unknown): NewRatingInput { + const b = (body ?? {}) as Record; + + const spectrum = b.spectrum === undefined ? null : b.spectrum; + const biasRating = b.biasRating === undefined ? null : b.biasRating; + if (spectrum !== null && spectrum !== "IN") throw new ValidationError("invalid_spectrum"); + if (biasRating !== null && !BIAS_RATINGS.has(biasRating as string)) throw new ValidationError("invalid_bias_rating"); + if ((biasRating === null) !== (spectrum === null)) throw new ValidationError("bias_needs_spectrum"); + + if (typeof b.factuality !== "string" || !FACTUALITY_VALUES.has(b.factuality)) { + throw new ValidationError("invalid_factuality"); + } + const confidence = b.confidence === undefined ? null : b.confidence; + if (confidence !== null && !CONFIDENCE_VALUES.has(confidence as string)) { + throw new ValidationError("invalid_confidence"); + } + + if (typeof b.method !== "string" || b.method.length === 0) throw new ValidationError("invalid_method"); + if (typeof b.ratedBy !== "string" || b.ratedBy.length === 0) throw new ValidationError("invalid_rated_by"); + + const secondRater = typeof b.secondRater === "string" ? b.secondRater : null; + const evidence = Array.isArray(b.evidence) ? b.evidence : undefined; + const rubricScores = typeof b.rubricScores === "object" && b.rubricScores !== null ? b.rubricScores : undefined; + + // manual_needs_two_raters_and_evidence (§4): enforced here too, so curator tooling gets a + // readable 400 instead of the DB's raw constraint-violation error. + if (b.method === "manual-curation") { + if (secondRater === null || secondRater === b.ratedBy) throw new ValidationError("manual_needs_second_rater"); + if (rubricScores === undefined) throw new ValidationError("manual_needs_rubric_scores"); + if (evidence === undefined || evidence.length < 3) throw new ValidationError("manual_needs_evidence"); + } + + return { + spectrum: spectrum as string | null, + biasRating: biasRating as string | null, + factuality: b.factuality as "high" | "mixed" | "low", + confidence: confidence as "high" | "medium" | "low" | null, + method: b.method, + methodVersion: typeof b.methodVersion === "string" ? b.methodVersion : null, + ratedBy: b.ratedBy, + secondRater, + rubricScores, + evidence, + reviewDueAt: typeof b.reviewDueAt === "string" ? new Date(b.reviewDueAt) : null, + sourceRef: typeof b.sourceRef === "string" ? b.sourceRef : null, + notes: typeof b.notes === "string" ? b.notes : null, + }; +} + +export async function submitRating(pool: Pool, sourceId: string, input: NewRatingInput): Promise { + if (!(await sourceExists(pool, sourceId))) throw new SourceNotFoundError(); + const row = await withTransaction(pool, (client) => reviseRating(client, sourceId, input)); + return toResponse(row); +} diff --git a/apps/api/src/features/saved/repo.ts b/apps/api/src/features/saved/repo.ts new file mode 100644 index 0000000..6ba11b8 --- /dev/null +++ b/apps/api/src/features/saved/repo.ts @@ -0,0 +1,51 @@ +import type { Pool } from "pg"; + +// The three saved-item tables (user_saved_sources/stories/topics) are structurally identical +// — one config object per resource type stands in for three near-duplicate modules. +export interface SavedResourceConfig { + table: string; // developer-controlled constant, never user input — safe to interpolate + itemColumn: string; + existsTable: string; +} + +export const SAVED_SOURCES: SavedResourceConfig = { + table: "user_saved_sources", + itemColumn: "source_id", + existsTable: "sources", +}; +export const SAVED_STORIES: SavedResourceConfig = { + table: "user_saved_stories", + itemColumn: "story_id", + existsTable: "stories", +}; +export const SAVED_TOPICS: SavedResourceConfig = { + table: "user_saved_topics", + itemColumn: "topic_id", + existsTable: "topics", +}; + +export async function itemExists(pool: Pool, config: SavedResourceConfig, itemId: string): Promise { + const result = await pool.query(`SELECT 1 FROM ${config.existsTable} WHERE id = $1`, [itemId]); + return result.rowCount !== null && result.rowCount > 0; +} + +// Idempotent by primary key (§10): saving twice, or unsaving something never saved, are both +// no-ops rather than errors. +export async function saveItem(pool: Pool, config: SavedResourceConfig, userId: string, itemId: string): Promise { + await pool.query( + `INSERT INTO ${config.table} (user_id, ${config.itemColumn}) VALUES ($1, $2) ON CONFLICT DO NOTHING`, + [userId, itemId] + ); +} + +export async function unsaveItem(pool: Pool, config: SavedResourceConfig, userId: string, itemId: string): Promise { + await pool.query(`DELETE FROM ${config.table} WHERE user_id = $1 AND ${config.itemColumn} = $2`, [userId, itemId]); +} + +export async function listSavedItems(pool: Pool, config: SavedResourceConfig, userId: string): Promise { + const result = await pool.query<{ id: string }>( + `SELECT ${config.itemColumn} AS id FROM ${config.table} WHERE user_id = $1`, + [userId] + ); + return result.rows.map((r) => r.id); +} diff --git a/apps/api/src/features/saved/routes.ts b/apps/api/src/features/saved/routes.ts new file mode 100644 index 0000000..6a26cdb --- /dev/null +++ b/apps/api/src/features/saved/routes.ts @@ -0,0 +1,34 @@ +import type { FastifyInstance } from "fastify"; + +import { getPool } from "../../db.js"; +import { itemExists, listSavedItems, saveItem, SAVED_SOURCES, SAVED_STORIES, SAVED_TOPICS, unsaveItem, type SavedResourceConfig } from "./repo.js"; + +function registerSavedResource(app: FastifyInstance, path: string, config: SavedResourceConfig): void { + app.get(path, async (request) => { + const items = await listSavedItems(getPool(), config, request.userId as string); + return { items }; + }); + + // §10 idempotency: PUT/DELETE /api/me/saved-{stories,sources,topics}/:id are idempotent by + // primary key — 204 either way, including for an id that was never saved. + app.put(`${path}/:id`, async (request, reply) => { + const { id } = request.params as { id: string }; + if (!(await itemExists(getPool(), config, id))) { + return reply.code(404).send({ error: "not_found" }); + } + await saveItem(getPool(), config, request.userId as string, id); + return reply.code(204).send(); + }); + + app.delete(`${path}/:id`, async (request, reply) => { + const { id } = request.params as { id: string }; + await unsaveItem(getPool(), config, request.userId as string, id); + return reply.code(204).send(); + }); +} + +export async function savedRoutes(app: FastifyInstance): Promise { + registerSavedResource(app, "/me/saved-sources", SAVED_SOURCES); + registerSavedResource(app, "/me/saved-stories", SAVED_STORIES); + registerSavedResource(app, "/me/saved-topics", SAVED_TOPICS); +} diff --git a/apps/api/src/features/story/repo.ts b/apps/api/src/features/story/repo.ts new file mode 100644 index 0000000..ff1d742 --- /dev/null +++ b/apps/api/src/features/story/repo.ts @@ -0,0 +1,74 @@ +import type { Pool } from "pg"; + +export interface StoryDetailRow { + id: string; + title: string; + summary: string | null; + status: "building" | "active" | "tombstoned"; + merged_into: string | null; + merged_into_title: string | null; + last_coverage_at: Date; +} + +export async function fetchStory(pool: Pool, storyId: string): Promise { + const result = await pool.query( + `SELECT s.id, s.title, s.summary, s.status, s.merged_into, m.title AS merged_into_title, s.last_coverage_at + FROM stories s + LEFT JOIN stories m ON m.id = s.merged_into + WHERE s.id = $1`, + [storyId] + ); + return result.rows[0] ?? null; +} + +export interface SplitNoticeRow { + new_story_id: string; + new_story_title: string; + article_count: number; +} + +// §6: story_lineage records every (old, new) split; merged_into (fetchStory above) is the +// primary successor for a tombstoned story, so it's excluded here to avoid showing it twice. +export async function fetchSplitNotices(pool: Pool, storyId: string, mergedInto: string | null): Promise { + const result = await pool.query<{ new_story_id: string; new_story_title: string; article_count: string }>( + `SELECT l.new_story_id, s.title AS new_story_title, l.article_count + FROM story_lineage l + JOIN stories s ON s.id = l.new_story_id + WHERE l.old_story_id = $1 AND l.new_story_id IS DISTINCT FROM $2 + ORDER BY l.article_count DESC`, + [storyId, mergedInto] + ); + return result.rows.map((r) => ({ ...r, article_count: Number(r.article_count) })); +} + +export type BiasCategory = "left" | "center-left" | "center" | "center-right" | "right" | "not_rated"; + +export interface StoryArticleRow { + id: string; + title: string; + url: string; + published_at: Date | null; + effective_at: Date; + content_hash: string; + source_id: string; + source_name: string; + category: BiasCategory; +} + +// PRODUCT §3.3's category rule, same as the feed's bias bar — per-article here (not +// aggregated) so the caller can group by source and dedupe by content_hash. +export async function fetchStoryArticles(pool: Pool, storyId: string): Promise { + const result = await pool.query( + `SELECT a.id, a.title, a.url, a.published_at, a.effective_at, a.content_hash, + a.source_id, s.name AS source_name, + coalesce(r.bias_rating, 'not_rated') AS category + FROM articles a + JOIN sources s ON s.id = a.source_id + LEFT JOIN source_ratings r + ON r.source_id = a.source_id AND r.superseded_at IS NULL AND r.spectrum = 'IN' + WHERE a.story_id = $1 AND a.hidden_at IS NULL + ORDER BY a.source_id, a.effective_at DESC`, + [storyId] + ); + return result.rows; +} diff --git a/apps/api/src/features/story/routes.ts b/apps/api/src/features/story/routes.ts new file mode 100644 index 0000000..5257f86 --- /dev/null +++ b/apps/api/src/features/story/routes.ts @@ -0,0 +1,32 @@ +import type { FastifyInstance } from "fastify"; + +import { getPool } from "../../db.js"; +import { getCachedPage, setCachedPage } from "../../page-cache.js"; +import { getStoryDetail, StoryNotFoundError } from "./service.js"; + +const CACHE_TTL_SECONDS = 60; + +export async function storyRoutes(app: FastifyInstance): Promise { + app.get("/stories/:id", async (request, reply) => { + const { id } = request.params as { id: string }; + const cacheKey = `page:story:${id}`; + + const cached = await getCachedPage(cacheKey); + if (cached !== null) { + return reply.send(JSON.parse(cached)); + } + + let result; + try { + result = await getStoryDetail(getPool(), id); + } catch (err) { + if (err instanceof StoryNotFoundError) { + return reply.code(404).send({ error: "not_found" }); + } + throw err; + } + + await setCachedPage(cacheKey, JSON.stringify(result), CACHE_TTL_SECONDS); + return reply.send(result); + }); +} diff --git a/apps/api/src/features/story/service.ts b/apps/api/src/features/story/service.ts new file mode 100644 index 0000000..556602e --- /dev/null +++ b/apps/api/src/features/story/service.ts @@ -0,0 +1,143 @@ +import type { Pool } from "pg"; + +import { fetchRatedCounts } from "../feed/repo.js"; +import { fetchSplitNotices, fetchStory, fetchStoryArticles, type BiasCategory, type StoryArticleRow } from "./repo.js"; + +export class StoryNotFoundError extends Error {} + +export interface SourceCard { + sourceId: string; + sourceName: string; + primaryArticle: { id: string; title: string; url: string; publishedAt: string | null }; + moreCount: number; +} + +export interface BiasColumns { + left: SourceCard[]; + centerLeft: SourceCard[]; + center: SourceCard[]; + centerRight: SourceCard[]; + right: SourceCard[]; + notRated: SourceCard[]; +} + +export interface StoryDetailResponse { + id: string; + title: string; + summary: string | null; + status: "building" | "active" | "tombstoned"; + redirectTo: { id: string; title: string } | null; + splitNotices: { storyId: string; title: string; articleCount: number }[]; + ratedSourceCount: number; + unratedSourceCount: number; + biasColumns: BiasColumns; +} + +const CATEGORY_KEY: Record = { + left: "left", + "center-left": "centerLeft", + center: "center", + "center-right": "centerRight", + right: "right", + not_rated: "notRated", +}; + +// One card per source: dedupe that source's articles by content_hash (§10 "same content_hash +// shown once"), keep the most recent as the primary card, collapse the rest into moreCount. +function buildSourceCards(articles: StoryArticleRow[]): SourceCard[] { + const bySource = new Map(); + for (const article of articles) { + const list = bySource.get(article.source_id) ?? []; + list.push(article); + bySource.set(article.source_id, list); + } + + const cards: SourceCard[] = []; + for (const [sourceId, sourceArticles] of bySource) { + const seenHashes = new Set(); + const deduped = sourceArticles.filter((a) => { + if (seenHashes.has(a.content_hash)) return false; + seenHashes.add(a.content_hash); + return true; + }); + const [primary, ...rest] = deduped; // already ordered effective_at DESC by the query + cards.push({ + sourceId, + sourceName: primary.source_name, + primaryArticle: { + id: primary.id, + title: primary.title, + url: primary.url, + publishedAt: primary.published_at?.toISOString() ?? null, + }, + moreCount: rest.length, + }); + } + return cards; +} + +export async function getStoryDetail(pool: Pool, storyId: string): Promise { + const story = await fetchStory(pool, storyId); + if (story === null) throw new StoryNotFoundError(); + + const splitRows = await fetchSplitNotices(pool, storyId, story.merged_into); + const splitNotices = splitRows.map((r) => ({ + storyId: r.new_story_id, + title: r.new_story_title, + articleCount: r.article_count, + })); + + const emptyColumns = (): BiasColumns => ({ + left: [], + centerLeft: [], + center: [], + centerRight: [], + right: [], + notRated: [], + }); + + // A tombstoned story's visible coverage has moved to its successor — nothing to show here + // but the redirect itself (§6). + if (story.status === "tombstoned") { + return { + id: story.id, + title: story.title, + summary: story.summary, + status: story.status, + redirectTo: story.merged_into === null ? null : { id: story.merged_into, title: story.merged_into_title as string }, + splitNotices, + ratedSourceCount: 0, + unratedSourceCount: 0, + biasColumns: emptyColumns(), + }; + } + + const [articles, [ratedCounts]] = await Promise.all([ + fetchStoryArticles(pool, storyId), + fetchRatedCounts(pool, [storyId]), + ]); + + const byCategory = new Map(); + for (const article of articles) { + const list = byCategory.get(article.category) ?? []; + list.push(article); + byCategory.set(article.category, list); + } + + const biasColumns = emptyColumns(); + for (const [category, categoryArticles] of byCategory) { + biasColumns[CATEGORY_KEY[category]] = buildSourceCards(categoryArticles); + } + + return { + id: story.id, + title: story.title, + summary: story.summary, + status: story.status, + redirectTo: null, + splitNotices, + ratedSourceCount: ratedCounts?.rated_source_count ?? 0, + unratedSourceCount: ratedCounts?.unrated_source_count ?? 0, + biasColumns, + }; +} diff --git a/apps/api/src/features/topic/repo.ts b/apps/api/src/features/topic/repo.ts new file mode 100644 index 0000000..e4cffef --- /dev/null +++ b/apps/api/src/features/topic/repo.ts @@ -0,0 +1,66 @@ +import type { Pool } from "pg"; + +import type { StoryRow } from "../feed/repo.js"; + +export interface TopicRow { + id: string; + name: string; + keywords: string[]; +} + +export async function fetchTopic(pool: Pool, topicId: string): Promise { + const result = await pool.query("SELECT id, name, keywords FROM topics WHERE id = $1", [topicId]); + return result.rows[0] ?? null; +} + +export async function listTopics(pool: Pool): Promise { + const result = await pool.query("SELECT id, name, keywords FROM topics ORDER BY name"); + return result.rows; +} + +// §10 topic matching (pinned): OR across keywords via topic_pattern(), case-insensitive, +// whole word/phrase, against the headline/summary or any visible member article title, over +// stories with coverage in the last 14 days. The `as_of` ceiling isn't in the doc's own +// snippet but matches every other feed's cursor convention (freezes the scroll). +export async function fetchTopicFeedPage( + pool: Pool, + args: { topicId: string; asOf: Date; cursorCoverageAt: Date | null; cursorId: string | null; limit: number } +): Promise { + const result = await pool.query( + `WITH p AS ( + SELECT array_agg(topic_pattern(k)) AS patterns + FROM topics t, unnest(t.keywords) AS k + WHERE t.id = $1 + ) + SELECT s.id, s.title, s.summary, s.last_coverage_at + FROM stories s, p + WHERE s.status = 'active' AND s.hidden_at IS NULL + AND s.last_coverage_at <= $2 + AND s.last_coverage_at >= now() - interval '14 days' + AND ($3::timestamptz IS NULL OR (s.last_coverage_at, s.id) < ($3, $4)) + AND ( (s.title || ' ' || coalesce(s.summary, '')) ~* ANY(p.patterns) + OR EXISTS (SELECT 1 FROM articles a + WHERE a.story_id = s.id AND a.hidden_at IS NULL AND a.title ~* ANY(p.patterns)) ) + ORDER BY s.last_coverage_at DESC, s.id DESC + LIMIT $5`, + [args.topicId, args.asOf, args.cursorCoverageAt, args.cursorId, args.limit] + ); + return result.rows; +} + +export async function countTopicUpdatedSince(pool: Pool, topicId: string, asOf: Date): Promise { + const result = await pool.query<{ count: string }>( + `WITH p AS ( + SELECT array_agg(topic_pattern(k)) AS patterns + FROM topics t, unnest(t.keywords) AS k + WHERE t.id = $1 + ) + SELECT count(*) FROM stories s, p + WHERE s.status = 'active' AND s.hidden_at IS NULL AND s.last_coverage_at > $2 + AND ( (s.title || ' ' || coalesce(s.summary, '')) ~* ANY(p.patterns) + OR EXISTS (SELECT 1 FROM articles a + WHERE a.story_id = s.id AND a.hidden_at IS NULL AND a.title ~* ANY(p.patterns)) )`, + [topicId, asOf] + ); + return Number(result.rows[0].count); +} diff --git a/apps/api/src/features/topic/routes.ts b/apps/api/src/features/topic/routes.ts new file mode 100644 index 0000000..91b205a --- /dev/null +++ b/apps/api/src/features/topic/routes.ts @@ -0,0 +1,50 @@ +import type { FastifyInstance } from "fastify"; + +import { getPool } from "../../db.js"; +import { getCachedPage, setCachedPage } from "../../page-cache.js"; +import { InvalidCursorError } from "../../pagination.js"; +import { getTopicFeed, getTopics, TopicNotFoundError } from "./service.js"; + +const CACHE_TTL_SECONDS = 60; + +export async function topicRoutes(app: FastifyInstance): Promise { + app.get("/topics", async (_request, reply) => { + const cacheKey = "page:topics:list"; + const cached = await getCachedPage(cacheKey); + if (cached !== null) { + return reply.send(JSON.parse(cached)); + } + + const topics = await getTopics(getPool()); + await setCachedPage(cacheKey, JSON.stringify(topics), CACHE_TTL_SECONDS); + return reply.send(topics); + }); + + app.get("/topics/:id/feed", async (request, reply) => { + const { id } = request.params as { id: string }; + const cursor = (request.query as { cursor?: unknown }).cursor; + const cursorRaw = typeof cursor === "string" ? cursor : undefined; + + const cacheKey = cursorRaw === undefined ? `page:topic:${id}:first` : `page:topic:${id}:cursor:${cursorRaw}`; + const cached = await getCachedPage(cacheKey); + if (cached !== null) { + return reply.send(JSON.parse(cached)); + } + + let result; + try { + result = await getTopicFeed(getPool(), id, cursorRaw); + } catch (err) { + if (err instanceof TopicNotFoundError) { + return reply.code(404).send({ error: "not_found" }); + } + if (err instanceof InvalidCursorError) { + return reply.code(400).send({ error: "invalid_cursor" }); + } + throw err; + } + + await setCachedPage(cacheKey, JSON.stringify(result), CACHE_TTL_SECONDS); + return reply.send(result); + }); +} diff --git a/apps/api/src/features/topic/service.ts b/apps/api/src/features/topic/service.ts new file mode 100644 index 0000000..2331371 --- /dev/null +++ b/apps/api/src/features/topic/service.ts @@ -0,0 +1,33 @@ +import type { Pool } from "pg"; + +import { buildPage, decodeCursor } from "../../pagination.js"; +import { buildFeedItems, type FeedResponse } from "../feed/service.js"; +import { countTopicUpdatedSince, fetchTopic, fetchTopicFeedPage, listTopics, type TopicRow } from "./repo.js"; + +const PAGE_SIZE = 20; + +export class TopicNotFoundError extends Error {} + +export async function getTopics(pool: Pool): Promise { + return listTopics(pool); +} + +export async function getTopicFeed(pool: Pool, topicId: string, cursorRaw: string | undefined): Promise { + const topic = await fetchTopic(pool, topicId); + if (topic === null) throw new TopicNotFoundError(); + + const feedName = `topic:${topicId}`; + const cursor = cursorRaw === undefined ? null : decodeCursor(cursorRaw, feedName); + const asOf = cursor === null ? new Date() : new Date(cursor.as_of); + const cursorCoverageAt = cursor === null ? null : new Date(cursor.c); + const cursorId = cursor === null ? null : cursor.id; + + const rows = await fetchTopicFeedPage(pool, { topicId, asOf, cursorCoverageAt, cursorId, limit: PAGE_SIZE + 1 }); + const page = buildPage(rows, PAGE_SIZE, feedName, asOf); + const [items, updatedCount] = await Promise.all([ + buildFeedItems(pool, page.items), + countTopicUpdatedSince(pool, topicId, asOf), + ]); + + return { items, asOf: page.as_of, nextCursor: page.next_cursor, updatedCount }; +} diff --git a/apps/api/src/page-cache.ts b/apps/api/src/page-cache.ts new file mode 100644 index 0000000..822e9ca --- /dev/null +++ b/apps/api/src/page-cache.ts @@ -0,0 +1,20 @@ +// Anonymous page cache (§10): first page key = route; later pages key = route + cursor. +// Redis outage: cache misses fall through to Postgres — caching is a perf optimization, so +// unlike rate limiting this fails OPEN (to the DB), never blocking a response. +import { getRedis } from "./redis.js"; + +export async function getCachedPage(key: string): Promise { + try { + return await getRedis().get(key); + } catch { + return null; + } +} + +export async function setCachedPage(key: string, value: string, ttlSeconds: number): Promise { + try { + await getRedis().set(key, value, "EX", ttlSeconds); + } catch { + // ignore — a cache write failure just means the next request recomputes too + } +} diff --git a/apps/api/src/pagination.ts b/apps/api/src/pagination.ts new file mode 100644 index 0000000..a2515aa --- /dev/null +++ b/apps/api/src/pagination.ts @@ -0,0 +1,74 @@ +// Opaque feed cursors (§10). The client never handles `as_of` or keys: the first request +// sends no cursor, the server picks `as_of = now()`, and every response returns +// { items, as_of, next_cursor }; the client sends next_cursor back unchanged. +import { createHmac, timingSafeEqual } from "node:crypto"; + +export interface CursorPayload { + v: 1; + feed: string; + as_of: string; + c: string; // last_coverage_at of the last item on the page + id: string; // that item's id (tiebreaker) +} + +// Keys rotate like JWT keys (current + previous): verification tries each in turn, since +// there's no kid marker in the payload — only ever two candidates. +const CURSOR_KEYS = (process.env.CURSOR_KEYS ?? "dev-insecure-cursor-key").split(","); +const SIGNATURE_LENGTH = 16; +const MAX_AS_OF_AGE_MS = 24 * 60 * 60 * 1000; + +export class InvalidCursorError extends Error {} + +function sign(key: string, payloadBytes: Buffer): Buffer { + return createHmac("sha256", key).update(payloadBytes).digest().subarray(0, SIGNATURE_LENGTH); +} + +export function encodeCursor(payload: CursorPayload): string { + const payloadBytes = Buffer.from(JSON.stringify(payload), "utf8"); + const signature = sign(CURSOR_KEYS[0], payloadBytes); + return Buffer.concat([payloadBytes, signature]).toString("base64url"); +} + +export function decodeCursor(cursor: string, feed: string): CursorPayload { + const buf = Buffer.from(cursor, "base64url"); + if (buf.length <= SIGNATURE_LENGTH) throw new InvalidCursorError("malformed"); + + const payloadBytes = buf.subarray(0, buf.length - SIGNATURE_LENGTH); + const signature = buf.subarray(buf.length - SIGNATURE_LENGTH); + const signatureOk = CURSOR_KEYS.some((key) => timingSafeEqual(sign(key, payloadBytes), signature)); + if (!signatureOk) throw new InvalidCursorError("bad_signature"); + + let payload: CursorPayload; + try { + payload = JSON.parse(payloadBytes.toString("utf8")); + } catch { + throw new InvalidCursorError("malformed_payload"); + } + if (payload.v !== 1) throw new InvalidCursorError("unknown_version"); + if (payload.feed !== feed) throw new InvalidCursorError("wrong_feed"); + if (Date.now() - Date.parse(payload.as_of) > MAX_AS_OF_AGE_MS) throw new InvalidCursorError("as_of_too_old"); + return payload; +} + +export interface Page { + items: T[]; + as_of: string; + next_cursor: string | null; +} + +// `items` is the page fetched with LIMIT pageSize + 1 (one extra row to detect "more pages" +// without a separate count query); this trims it back to pageSize and builds the cursor. +export function buildPage( + items: T[], + pageSize: number, + feed: string, + asOf: Date +): Page { + const hasMore = items.length > pageSize; + const pageItems = hasMore ? items.slice(0, pageSize) : items; + const last = pageItems[pageItems.length - 1]; + const next_cursor = hasMore + ? encodeCursor({ v: 1, feed, as_of: asOf.toISOString(), c: last.last_coverage_at.toISOString(), id: last.id }) + : null; + return { items: pageItems, as_of: asOf.toISOString(), next_cursor }; +} diff --git a/apps/api/src/plugins/curator.ts b/apps/api/src/plugins/curator.ts new file mode 100644 index 0000000..39b1917 --- /dev/null +++ b/apps/api/src/plugins/curator.ts @@ -0,0 +1,15 @@ +// /api/curator/* scope: a shared-secret header, not user auth. Decision: a handful of +// internal curators don't warrant a users.is_curator role/schema change at MVP — matches the +// complexity-budget philosophy applied everywhere else in this codebase. +import type { FastifyInstance } from "fastify"; + +const CURATOR_API_KEY = process.env.CURATOR_API_KEY ?? "dev-insecure-curator-key"; +const HEADER = "x-curator-key"; + +export async function curatorPlugin(app: FastifyInstance): Promise { + app.addHook("onRequest", async (request, reply) => { + if (request.headers[HEADER] !== CURATOR_API_KEY) { + return reply.code(401).send({ error: "unauthorized" }); + } + }); +} diff --git a/apps/api/test/app.test.ts b/apps/api/test/app.test.ts index b20b756..e7c4bd2 100644 --- a/apps/api/test/app.test.ts +++ b/apps/api/test/app.test.ts @@ -51,3 +51,28 @@ describe("CORS headers", () => { await app.close(); }); }); + +describe("cache isolation (§10)", () => { + it("/auth/* responses never cache", async () => { + const app = buildApp(); + const res = await app.inject({ + method: "POST", + url: "/auth/signup", + headers: { origin: WEB_ORIGIN, "content-type": "application/json" }, + payload: { email: "not-an-email", password: "x" }, + }); + expect(res.headers["cache-control"]).toBe("no-store"); + await app.close(); + }); + + it("authenticated /api/* responses are private, no-store", async () => { + const app = buildApp(); + const res = await app.inject({ method: "GET", url: "/api/whoami" }); + expect(res.headers["cache-control"]).toBe("private, no-store"); + await app.close(); + }); + + // /api/public/* 's own public, max-age=30 header is exercised by the feed endpoint's + // tests once it exists — cachePlugin's hook only runs for routes actually registered in + // that scope, same reason the auth-plugin test needed a real /api/whoami route. +}); diff --git a/apps/api/test/blindspot.test.ts b/apps/api/test/blindspot.test.ts new file mode 100644 index 0000000..fb4d60d --- /dev/null +++ b/apps/api/test/blindspot.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from "vitest"; + +import { getPool } from "../src/db.js"; +import { getBlindspotFeed } from "../src/features/blindspot/service.js"; +import { addCoverage, createTestSource, createTestStory, rateSource } from "./feed-helpers.js"; + +const BASELINE = { pLeft: 0.3, pCenter: 0.35, pRight: 0.35 }; + +async function insertBaseline(asOf: Date = new Date()): Promise { + await getPool().query( + `INSERT INTO bias_baselines (spectrum, method, appearances, p_left, p_center, p_right, computed_at) + VALUES ('IN', 'pool', 0, $1, $2, $3, $4)`, + [BASELINE.pLeft, BASELINE.pCenter, BASELINE.pRight, asOf] + ); +} + +describe("getBlindspotFeed (PRODUCT §4)", () => { + it("flags a story with 0 right-leaning sources at n=6 — the doc's own minimal example (§4.5)", async () => { + await insertBaseline(); + const sources = await Promise.all(Array.from({ length: 6 }, () => createTestSource("blindspot-left"))); + await Promise.all(sources.map((s) => rateSource(s, { biasRating: "left", confidence: "high" }))); + + const { storyId } = await createTestStory(sources[0]); + await Promise.all(sources.slice(1).map((s) => addCoverage(storyId, s))); + + const result = await getBlindspotFeed(getPool(), undefined); + const item = result.items.find((i) => i.id === storyId); + expect(item).toBeDefined(); + expect(item?.side).toBe("R"); + expect(item?.countedLeft).toBe(6); + expect(item?.countedRight).toBe(0); + }); + + it("does not flag a story with fewer than 6 counted sources (n_min)", async () => { + await insertBaseline(); + const sources = await Promise.all(Array.from({ length: 5 }, () => createTestSource("blindspot-toofew"))); + await Promise.all(sources.map((s) => rateSource(s, { biasRating: "left", confidence: "high" }))); + + const { storyId } = await createTestStory(sources[0]); + await Promise.all(sources.slice(1).map((s) => addCoverage(storyId, s))); + + const result = await getBlindspotFeed(getPool(), undefined); + expect(result.items.find((i) => i.id === storyId)).toBeUndefined(); + }); + + it("does not flag balanced coverage (no side is one-sided)", async () => { + await insertBaseline(); + const left = await Promise.all(Array.from({ length: 3 }, () => createTestSource("blindspot-bal-left"))); + const center = await Promise.all(Array.from({ length: 3 }, () => createTestSource("blindspot-bal-center"))); + const right = await Promise.all(Array.from({ length: 2 }, () => createTestSource("blindspot-bal-right"))); + await Promise.all([ + ...left.map((s) => rateSource(s, { biasRating: "left", confidence: "high" })), + ...center.map((s) => rateSource(s, { biasRating: "center", confidence: "high" })), + ...right.map((s) => rateSource(s, { biasRating: "right", confidence: "high" })), + ]); + + const { storyId } = await createTestStory(left[0]); + await Promise.all([...left.slice(1), ...center, ...right].map((s) => addCoverage(storyId, s))); + + const result = await getBlindspotFeed(getPool(), undefined); + expect(result.items.find((i) => i.id === storyId)).toBeUndefined(); + }); + + it("excludes low-confidence ratings from the counted-source tally", async () => { + await insertBaseline(); + // 6 left sources at high confidence (flaggable per §4.5), plus a low-confidence right + // source that must NOT count toward k_right (PRODUCT §4.3: confidence <> 'low'). + const left = await Promise.all(Array.from({ length: 6 }, () => createTestSource("blindspot-lowconf-left"))); + await Promise.all(left.map((s) => rateSource(s, { biasRating: "left", confidence: "high" }))); + const lowConfRight = await createTestSource("blindspot-lowconf-right"); + await rateSource(lowConfRight, { biasRating: "right", confidence: "low" }); + + const { storyId } = await createTestStory(left[0]); + await Promise.all(left.slice(1).map((s) => addCoverage(storyId, s))); + await addCoverage(storyId, lowConfRight); + + const result = await getBlindspotFeed(getPool(), undefined); + const item = result.items.find((i) => i.id === storyId); + expect(item?.countedRight).toBe(0); + expect(item?.side).toBe("R"); + }); + + it("excludes stories whose coverage is older than the 7-day window", async () => { + await insertBaseline(); + const sources = await Promise.all(Array.from({ length: 6 }, () => createTestSource("blindspot-old"))); + await Promise.all(sources.map((s) => rateSource(s, { biasRating: "left", confidence: "high" }))); + + const old = new Date(Date.now() - 10 * 24 * 60 * 60 * 1000); + const { storyId } = await createTestStory(sources[0], { lastCoverageAt: old }); + await Promise.all(sources.slice(1).map((s) => addCoverage(storyId, s, { effectiveAt: old }))); + + const result = await getBlindspotFeed(getPool(), undefined); + expect(result.items.find((i) => i.id === storyId)).toBeUndefined(); + }); +}); diff --git a/apps/api/test/curator.test.ts b/apps/api/test/curator.test.ts new file mode 100644 index 0000000..15eafb3 --- /dev/null +++ b/apps/api/test/curator.test.ts @@ -0,0 +1,195 @@ +import { describe, expect, it } from "vitest"; + +import { buildApp } from "../src/app.js"; +import { getPool } from "../src/db.js"; +import { createTestSource, createTestStory } from "./feed-helpers.js"; + +const CURATOR_KEY = "dev-insecure-curator-key"; + +function post(app: ReturnType, path: string, body: unknown) { + return app.inject({ + method: "POST", + url: `/api/curator${path}`, + headers: { "content-type": "application/json", "x-curator-key": CURATOR_KEY }, + payload: body as object, + }); +} + +describe("curator auth", () => { + it("rejects every curator route without the key", async () => { + const app = buildApp(); + const res = await app.inject({ + method: "POST", + url: "/api/curator/sources", + headers: { "content-type": "application/json" }, + payload: {}, + }); + expect(res.statusCode).toBe(401); + await app.close(); + }); +}); + +describe("POST /api/curator/sources", () => { + it("onboards a new source", async () => { + const app = buildApp(); + const res = await post(app, "/sources", { + name: "Curator Test Source", + domain: `curator-onboard-${Date.now()}.example`, + countryCode: "IN", + language: "en", + feedTimezone: "Asia/Kolkata", + }); + expect(res.statusCode).toBe(201); + await app.close(); + }); + + it("rejects a missing required field", async () => { + const app = buildApp(); + const res = await post(app, "/sources", { name: "Missing fields" }); + expect(res.statusCode).toBe(400); + await app.close(); + }); +}); + +describe("POST /api/curator/topics", () => { + it("normalizes keywords: casefold, trim, collapse whitespace, drop duplicates", async () => { + const app = buildApp(); + const res = await post(app, "/topics", { + name: `Normalize test ${Date.now()}`, + keywords: [" AI ", "ai", "C++", " "], + createdBy: "curator-1", + }); + expect(res.statusCode).toBe(201); + + const row = await getPool().query("SELECT keywords FROM topics WHERE id = $1", [res.json().id]); + expect(row.rows[0].keywords).toEqual(["ai", "c++"]); + await app.close(); + }); + + it("rejects a non-Latin-script keyword", async () => { + const app = buildApp(); + const res = await post(app, "/topics", { + name: `Script test ${Date.now()}`, + keywords: ["चुनाव"], + createdBy: "curator-1", + }); + expect(res.statusCode).toBe(400); + expect(res.json()).toEqual({ error: "invalid_keyword_script" }); + await app.close(); + }); + + it("rejects more than 20 keywords", async () => { + const app = buildApp(); + const res = await post(app, "/topics", { + name: `Too many ${Date.now()}`, + keywords: Array.from({ length: 21 }, (_, i) => `keyword${i}`), + createdBy: "curator-1", + }); + expect(res.statusCode).toBe(400); + expect(res.json()).toEqual({ error: "invalid_keyword_count" }); + await app.close(); + }); +}); + +describe("POST /api/curator/articles/:id/hide", () => { + it("hides an article with a valid reason", async () => { + const sourceId = await createTestSource("curator-hide-article"); + const { headlineArticleId } = await createTestStory(sourceId); + const app = buildApp(); + + const res = await post(app, `/articles/${headlineArticleId}/hide`, { reason: "takedown" }); + expect(res.statusCode).toBe(204); + + const row = await getPool().query("SELECT hidden_at, hidden_reason FROM articles WHERE id = $1", [headlineArticleId]); + expect(row.rows[0].hidden_at).not.toBeNull(); + expect(row.rows[0].hidden_reason).toBe("takedown"); + await app.close(); + }); + + it("rejects an invalid reason", async () => { + const sourceId = await createTestSource("curator-hide-bad-reason"); + const { headlineArticleId } = await createTestStory(sourceId); + const app = buildApp(); + const res = await post(app, `/articles/${headlineArticleId}/hide`, { reason: "not-a-real-reason" }); + expect(res.statusCode).toBe(400); + await app.close(); + }); + + it("returns 404 for an article that doesn't exist", async () => { + const app = buildApp(); + const res = await post(app, "/articles/00000000-0000-0000-0000-000000000000/hide", { reason: "takedown" }); + expect(res.statusCode).toBe(404); + await app.close(); + }); +}); + +describe("POST /api/curator/stories/:id/hide", () => { + it("hides an active story", async () => { + const sourceId = await createTestSource("curator-hide-story"); + const { storyId } = await createTestStory(sourceId); + const app = buildApp(); + + const res = await post(app, `/stories/${storyId}/hide`, {}); + expect(res.statusCode).toBe(204); + + const row = await getPool().query("SELECT hidden_at FROM stories WHERE id = $1", [storyId]); + expect(row.rows[0].hidden_at).not.toBeNull(); + await app.close(); + }); +}); + +describe("POST /api/curator/cluster-reports", () => { + it("records a wrong_merge report", async () => { + const sourceId = await createTestSource("curator-cluster-report"); + const { storyId, headlineArticleId } = await createTestStory(sourceId); + const app = buildApp(); + + const res = await post(app, "/cluster-reports", { + kind: "wrong_merge", + storyId, + articleId: headlineArticleId, + reportedBy: "curator-1", + }); + expect(res.statusCode).toBe(201); + await app.close(); + }); + + it("rejects wrong_merge without an articleId", async () => { + const sourceId = await createTestSource("curator-cluster-missing-article"); + const { storyId } = await createTestStory(sourceId); + const app = buildApp(); + const res = await post(app, "/cluster-reports", { kind: "wrong_merge", storyId, reportedBy: "curator-1" }); + expect(res.statusCode).toBe(400); + expect(res.json()).toEqual({ error: "wrong_merge_needs_article_id" }); + await app.close(); + }); + + it("records a should_merge report referencing another story", async () => { + const sourceA = await createTestSource("curator-cluster-a"); + const sourceB = await createTestSource("curator-cluster-b"); + const { storyId } = await createTestStory(sourceA); + const { storyId: otherStoryId } = await createTestStory(sourceB); + const app = buildApp(); + + const res = await post(app, "/cluster-reports", { + kind: "should_merge", + storyId, + otherStoryId, + reportedBy: "curator-1", + }); + expect(res.statusCode).toBe(201); + await app.close(); + }); + + it("returns 404 for a story that doesn't exist", async () => { + const app = buildApp(); + const res = await post(app, "/cluster-reports", { + kind: "should_merge", + storyId: "00000000-0000-0000-0000-000000000000", + otherStoryId: "00000000-0000-0000-0000-000000000001", + reportedBy: "curator-1", + }); + expect(res.statusCode).toBe(404); + await app.close(); + }); +}); diff --git a/apps/api/test/feed-helpers.ts b/apps/api/test/feed-helpers.ts new file mode 100644 index 0000000..a01aa42 --- /dev/null +++ b/apps/api/test/feed-helpers.ts @@ -0,0 +1,159 @@ +// Fixtures for feed/topic/blindspot tests. Mirrors the real ingestion write order (clustering +// apply.py): an article is created first with no story, then a new story is created pointing +// at it as headline_article_id, then the article is updated to point back at the story — +// because stories.headline_article_id and articles.story_id each need the other to exist. +// +// No cleanup here: stories can never be hard-deleted once active (`stories_no_delete` trigger +// — only 'building' stories may be deleted, and the feed only reads 'active' ones, so a +// 'building' fixture would be invisible to the thing under test anyway), and sources/articles +// have NOT NULL FKs with no cascade once referenced. This matches the schema's own stance +// (§11: "Content: sources, articles, stories, ratings — indefinite" retention) — fixtures are +// real, permanent rows, so every call below generates its own unique domain/title/url instead +// of trying to reuse or delete anything. +import { getPool } from "../src/db.js"; + +const ZERO_VECTOR = `[${Array(384).fill(0).join(",")}]`; +let counter = 0; + +function uniqueSuffix(): string { + counter += 1; + return `${Date.now()}-${counter}`; +} + +export async function createTestSource(label: string): Promise { + const domain = `${label}-${uniqueSuffix()}.example`; + const result = await getPool().query<{ id: string }>( + `INSERT INTO sources (name, domain, country_code, coverage_region, language, feed_timezone) + VALUES ($1, $1, 'IN', 'domestic', 'en', 'Asia/Kolkata') + RETURNING id`, + [domain] + ); + return result.rows[0].id; +} + +export async function rateSource( + sourceId: string, + opts: { biasRating?: string | null; confidence?: string | null; factualityOnly?: boolean } +): Promise { + const spectrum = opts.factualityOnly ? null : "IN"; + const biasRating = opts.factualityOnly ? null : opts.biasRating ?? "center"; + // method='internal-classifier-v1', not 'manual-curation' — the latter requires a second + // rater, rubric scores and ≥3 evidence entries (manual_needs_two_raters_and_evidence). + await getPool().query( + `INSERT INTO source_ratings (source_id, spectrum, bias_rating, factuality, confidence, method, rated_by) + VALUES ($1, $2, $3, 'high', $4, 'internal-classifier-v1', 'test')`, + [sourceId, spectrum, biasRating, opts.confidence ?? "high"] + ); +} + +async function insertArticle( + sourceId: string, + opts: { + storyId: string | null; + effectiveAt: Date; + hiddenAt?: Date | null; + clusterVersion?: string; + title?: string; + contentHash?: string; + } +): Promise { + const suffix = uniqueSuffix(); + const embedded = opts.storyId !== null; // I1: embedding/embedding_model/embedded_content_hash are null together + const hiddenAt = opts.hiddenAt ?? null; + const contentHash = opts.contentHash ?? `hash-${suffix}`; + const result = await getPool().query<{ id: string }>( + `INSERT INTO articles ( + source_id, title, url, canonical_url, canonicalization_version, content_hash, + effective_at, first_scraped_at, embedding, embedding_model, embedded_content_hash, + story_id, cluster_version, hidden_at, hidden_reason + ) VALUES ($1, $2, $3, $3, 'c1', $4, $5, $5, $6::vector, $7, $8, $9, $10, $11, $12) + RETURNING id`, + [ + sourceId, + opts.title ?? `Test article ${suffix}`, + `example.test/${suffix}`, + contentHash, + opts.effectiveAt, + embedded ? ZERO_VECTOR : null, + embedded ? "test-model" : null, + embedded ? contentHash : null, + opts.storyId, + embedded ? opts.clusterVersion ?? "test-v1" : null, + hiddenAt, + hiddenAt === null ? null : "takedown", // (hidden_at IS NULL) = (hidden_reason IS NULL) + ] + ); + return result.rows[0].id; +} + +export async function createTestStory( + sourceId: string, + opts: { title?: string; lastCoverageAt?: Date; clusterVersion?: string } = {} +): Promise<{ storyId: string; headlineArticleId: string }> { + const clusterVersion = opts.clusterVersion ?? "test-v1"; + const effectiveAt = opts.lastCoverageAt ?? new Date(); + const headlineArticleId = await insertArticle(sourceId, { storyId: null, effectiveAt }); + + const story = await getPool().query<{ id: string }>( + `INSERT INTO stories ( + title, summary, headline_article_id, representative_embedding, representative_embedding_model, + cluster_version, status, first_seen_at, event_last_updated_at, last_coverage_at + ) VALUES ($1, 'test summary', $2, $3::vector, 'test-model', $4, 'active', $5, $5, $5) + RETURNING id`, + [opts.title ?? `Test story ${uniqueSuffix()}`, headlineArticleId, ZERO_VECTOR, clusterVersion, effectiveAt] + ); + const storyId = story.rows[0].id; + + // Must flip story_id and embedding/embedding_model/embedded_content_hash together — I1 + // requires them null (or not) in lockstep, and before this the headline article had none. + await getPool().query( + `UPDATE articles + SET story_id = $1, cluster_version = $2, embedding = $3::vector, + embedding_model = 'test-model', embedded_content_hash = content_hash + WHERE id = $4`, + [storyId, clusterVersion, ZERO_VECTOR, headlineArticleId] + ); + + return { storyId, headlineArticleId }; +} + +// Adds another source's coverage to an existing story (for multi-source bias-bar tests). +export async function addCoverage( + storyId: string, + sourceId: string, + opts: { effectiveAt?: Date; hiddenAt?: Date | null; clusterVersion?: string; title?: string; contentHash?: string } = {} +): Promise { + return insertArticle(sourceId, { + storyId, + effectiveAt: opts.effectiveAt ?? new Date(), + hiddenAt: opts.hiddenAt, + clusterVersion: opts.clusterVersion, + title: opts.title, + contentHash: opts.contentHash, + }); +} + +// §6: redirect a story to its successor. 'stories_guard' requires the target be active. +export async function tombstoneStory(storyId: string, mergedIntoId: string): Promise { + await getPool().query( + "UPDATE stories SET status = 'tombstoned', tombstoned_at = now(), merged_into = $2 WHERE id = $1", + [storyId, mergedIntoId] + ); +} + +export async function insertLineage(oldStoryId: string, newStoryId: string, articleCount: number): Promise { + await getPool().query( + "INSERT INTO story_lineage (old_story_id, new_story_id, article_count) VALUES ($1, $2, $3)", + [oldStoryId, newStoryId, articleCount] + ); +} + +// Raw insert — bypasses the §10 write-side keyword normalization (curator-only, not built +// yet); fine for testing the read-side matching, which is all this is used for. +export async function createTestTopic(name: string, keywords: string[]): Promise { + const result = await getPool().query<{ id: string }>( + "INSERT INTO topics (name, keywords, created_by) VALUES ($1, $2, 'test') RETURNING id", + [`${name} ${uniqueSuffix()}`, keywords] + ); + return result.rows[0].id; +} diff --git a/apps/api/test/feed.test.ts b/apps/api/test/feed.test.ts new file mode 100644 index 0000000..cbbcc7b --- /dev/null +++ b/apps/api/test/feed.test.ts @@ -0,0 +1,114 @@ +import { beforeAll, describe, expect, it } from "vitest"; + +import { buildApp } from "../src/app.js"; +import { getPool } from "../src/db.js"; +import { fetchBiasBar, fetchRatedCounts } from "../src/features/feed/repo.js"; +import { encodeCursor } from "../src/pagination.js"; +import { addCoverage, createTestSource, createTestStory, rateSource } from "./feed-helpers.js"; +import { delTestCache, setTestCache } from "./redis-helpers.js"; + +describe("bias bar + rated counts (PRODUCT §3.3, ARCHITECTURE §10)", () => { + let storyId: string; + let leftSourceId: string; + let rightLowConfSourceId: string; + let internationalSourceId: string; + let unratedSourceId: string; + let hiddenSourceId: string; + + beforeAll(async () => { + leftSourceId = await createTestSource("left"); + rightLowConfSourceId = await createTestSource("right-low"); + internationalSourceId = await createTestSource("intl"); + unratedSourceId = await createTestSource("unrated"); + hiddenSourceId = await createTestSource("hidden"); + + await rateSource(leftSourceId, { biasRating: "left", confidence: "high" }); + await rateSource(rightLowConfSourceId, { biasRating: "right", confidence: "low" }); + await rateSource(internationalSourceId, { factualityOnly: true }); + await rateSource(hiddenSourceId, { biasRating: "center", confidence: "high" }); + // unratedSourceId is deliberately left with no current rating. + + const story = await createTestStory(leftSourceId); + storyId = story.storyId; + await addCoverage(storyId, rightLowConfSourceId); + await addCoverage(storyId, internationalSourceId); + await addCoverage(storyId, unratedSourceId); + await addCoverage(storyId, hiddenSourceId, { hiddenAt: new Date() }); + }); + + it("buckets left / low-confidence right / not_rated correctly, excluding hidden articles", async () => { + const rows = await fetchBiasBar(getPool(), [storyId]); + const byCategory = Object.fromEntries(rows.map((r) => [r.category, r])); + + expect(byCategory.left).toMatchObject({ total: 1, low_confidence: 0 }); + expect(byCategory.right).toMatchObject({ total: 1, low_confidence: 1 }); + // not_rated covers both the international (factuality-only) source and the truly unrated + // one — §3.2: international outlets "sit outside the bar as not rated on this scale". + expect(byCategory.not_rated).toMatchObject({ total: 2 }); + expect(byCategory["center"]).toBeUndefined(); // hidden source's article doesn't count at all + }); + + it("counts the international source as rated, but the truly unrated one as unrated", async () => { + const [counts] = await fetchRatedCounts(getPool(), [storyId]); + // left + right + international = 3 rated; the unrated source = 1. Hidden source excluded. + expect(counts.rated_source_count).toBe(3); + expect(counts.unrated_source_count).toBe(1); + }); +}); + +describe("GET /api/public/feed", () => { + it("returns a correctly-shaped page and includes a just-created story first", async () => { + const sourceId = await createTestSource("feed-http"); + const { storyId } = await createTestStory(sourceId, { lastCoverageAt: new Date() }); + await delTestCache("page:feed:home:first"); // don't let a stale cache hide this story + + const app = buildApp(); + const res = await app.inject({ method: "GET", url: "/api/public/feed" }); + expect(res.statusCode).toBe(200); + + const body = res.json(); + expect(body).toHaveProperty("items"); + expect(body).toHaveProperty("asOf"); + expect(body).toHaveProperty("nextCursor"); + expect(body).toHaveProperty("updatedCount"); + expect(body.items[0].id).toBe(storyId); + expect(body.items[0].biasBar).toHaveProperty("notRated"); + await app.close(); + }); + + it("rejects a malformed cursor with 400 invalid_cursor", async () => { + const app = buildApp(); + const res = await app.inject({ method: "GET", url: "/api/public/feed?cursor=not-a-real-cursor" }); + expect(res.statusCode).toBe(400); + expect(res.json()).toEqual({ error: "invalid_cursor" }); + await app.close(); + }); + + it("rejects a cursor issued for a different feed", async () => { + const foreignCursor = encodeCursor({ v: 1, feed: "topic:123", as_of: new Date().toISOString(), c: new Date().toISOString(), id: "x" }); + const app = buildApp(); + const res = await app.inject({ method: "GET", url: `/api/public/feed?cursor=${encodeURIComponent(foreignCursor)}` }); + expect(res.statusCode).toBe(400); + await app.close(); + }); + + it("serves the cached body on a repeat request instead of recomputing", async () => { + const fake = { items: [], asOf: new Date().toISOString(), nextCursor: null, updatedCount: 0, marker: "from-cache" }; + await setTestCache("page:feed:home:first", JSON.stringify(fake), 60); + + const app = buildApp(); + const res = await app.inject({ method: "GET", url: "/api/public/feed" }); + expect(res.json()).toEqual(fake); + await app.close(); + + await delTestCache("page:feed:home:first"); + }); + + it("carries the public cache header, not the private one", async () => { + await delTestCache("page:feed:home:first"); + const app = buildApp(); + const res = await app.inject({ method: "GET", url: "/api/public/feed" }); + expect(res.headers["cache-control"]).toBe("public, max-age=30"); + await app.close(); + }); +}); diff --git a/apps/api/test/pagination.test.ts b/apps/api/test/pagination.test.ts new file mode 100644 index 0000000..3d12ddd --- /dev/null +++ b/apps/api/test/pagination.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest"; + +import { buildPage, decodeCursor, encodeCursor, InvalidCursorError } from "../src/pagination.js"; + +describe("cursor encode/decode", () => { + const payload = { v: 1 as const, feed: "home", as_of: new Date().toISOString(), c: new Date().toISOString(), id: "abc" }; + + it("round-trips a valid cursor", () => { + const cursor = encodeCursor(payload); + expect(decodeCursor(cursor, "home")).toEqual(payload); + }); + + it("rejects a cursor issued for a different feed", () => { + const cursor = encodeCursor(payload); + expect(() => decodeCursor(cursor, "topic:123")).toThrow(InvalidCursorError); + }); + + it("rejects a tampered cursor", () => { + const cursor = encodeCursor(payload); + const buf = Buffer.from(cursor, "base64url"); + buf[0] ^= 0xff; + expect(() => decodeCursor(buf.toString("base64url"), "home")).toThrow(InvalidCursorError); + }); + + it("rejects a cursor whose as_of is older than 24h", () => { + const old = { ...payload, as_of: new Date(Date.now() - 25 * 60 * 60 * 1000).toISOString() }; + const cursor = encodeCursor(old); + expect(() => decodeCursor(cursor, "home")).toThrow(InvalidCursorError); + }); + + it("rejects garbage input", () => { + expect(() => decodeCursor("not-a-real-cursor", "home")).toThrow(InvalidCursorError); + }); +}); + +describe("buildPage", () => { + const asOf = new Date(); + function item(id: string, hoursAgo: number) { + return { id, last_coverage_at: new Date(asOf.getTime() - hoursAgo * 3600_000) }; + } + + it("has no next_cursor when there are no more rows than the page size", () => { + const page = buildPage([item("1", 0), item("2", 1)], 2, "home", asOf); + expect(page.items).toHaveLength(2); + expect(page.next_cursor).toBeNull(); + expect(page.as_of).toBe(asOf.toISOString()); + }); + + it("trims the extra row and sets a usable next_cursor when there are more", () => { + const page = buildPage([item("1", 0), item("2", 1), item("3", 2)], 2, "home", asOf); + expect(page.items.map((i) => i.id)).toEqual(["1", "2"]); + expect(page.next_cursor).not.toBeNull(); + + const decoded = decodeCursor(page.next_cursor as string, "home"); + expect(decoded.id).toBe("2"); + expect(decoded.c).toBe(item("2", 1).last_coverage_at.toISOString()); + }); +}); diff --git a/apps/api/test/rating.test.ts b/apps/api/test/rating.test.ts new file mode 100644 index 0000000..1d10bcf --- /dev/null +++ b/apps/api/test/rating.test.ts @@ -0,0 +1,154 @@ +import { describe, expect, it } from "vitest"; + +import { buildApp } from "../src/app.js"; +import { createTestSource } from "./feed-helpers.js"; +import { delTestCache } from "./redis-helpers.js"; + +const CURATOR_KEY = "dev-insecure-curator-key"; + +function postRating(app: ReturnType, sourceId: string, body: unknown, headers: Record = {}) { + return app.inject({ + method: "POST", + url: `/api/curator/sources/${sourceId}/ratings`, + headers: { "content-type": "application/json", "x-curator-key": CURATOR_KEY, ...headers }, + payload: body as object, + }); +} + +describe("GET /api/public/sources/:id/ratings", () => { + it("returns null current and empty history for an unrated source", async () => { + const sourceId = await createTestSource("rating-unrated"); + const app = buildApp(); + const res = await app.inject({ method: "GET", url: `/api/public/sources/${sourceId}/ratings` }); + expect(res.statusCode).toBe(200); + expect(res.json()).toEqual({ current: null, history: [] }); + await app.close(); + }); + + it("returns 404 for a nonexistent source", async () => { + const app = buildApp(); + const res = await app.inject({ + method: "GET", + url: "/api/public/sources/00000000-0000-0000-0000-000000000000/ratings", + }); + expect(res.statusCode).toBe(404); + await app.close(); + }); +}); + +describe("POST /api/curator/sources/:id/ratings", () => { + it("rejects a request without the curator key", async () => { + const sourceId = await createTestSource("rating-no-key"); + const app = buildApp(); + const res = await postRating(app, sourceId, { factuality: "high", method: "internal-classifier-v1", ratedBy: "x" }, { + "x-curator-key": "wrong", + }); + expect(res.statusCode).toBe(401); + await app.close(); + }); + + it("is not CSRF-gated: no Origin header required", async () => { + const sourceId = await createTestSource("rating-no-origin"); + const app = buildApp(); + const res = await postRating(app, sourceId, { + factuality: "high", + confidence: "high", + method: "internal-classifier-v1", + ratedBy: "curator-1", + }); + expect(res.statusCode).toBe(201); + await app.close(); + }); + + it("creates a rating, then supersedes it atomically on revision", async () => { + const sourceId = await createTestSource("rating-revision"); + const app = buildApp(); + + const first = await postRating(app, sourceId, { + spectrum: "IN", + biasRating: "left", + factuality: "high", + confidence: "high", + method: "internal-classifier-v1", + ratedBy: "curator-1", + }); + expect(first.statusCode).toBe(201); + const firstId = first.json().id; + + await delTestCache(`page:source-ratings:${sourceId}`); + const second = await postRating(app, sourceId, { + spectrum: "IN", + biasRating: "center", + factuality: "high", + confidence: "high", + method: "internal-classifier-v1", + ratedBy: "curator-1", + }); + expect(second.statusCode).toBe(201); + expect(second.json().supersedes).toBe(firstId); + + const getRes = await app.inject({ method: "GET", url: `/api/public/sources/${sourceId}/ratings` }); + const body = getRes.json(); + expect(body.current.id).toBe(second.json().id); + expect(body.current.biasRating).toBe("center"); + expect(body.history).toHaveLength(2); + expect(body.history.find((r: { id: string }) => r.id === firstId).supersededAt).not.toBeNull(); + await app.close(); + }); + + it("rejects bias_rating without a spectrum (bias_needs_spectrum)", async () => { + const sourceId = await createTestSource("rating-bias-no-spectrum"); + const app = buildApp(); + const res = await postRating(app, sourceId, { + biasRating: "left", + factuality: "high", + method: "internal-classifier-v1", + ratedBy: "curator-1", + }); + expect(res.statusCode).toBe(400); + expect(res.json()).toEqual({ error: "bias_needs_spectrum" }); + await app.close(); + }); + + it("rejects manual-curation without a second rater", async () => { + const sourceId = await createTestSource("rating-manual-missing"); + const app = buildApp(); + const res = await postRating(app, sourceId, { + factuality: "high", + method: "manual-curation", + ratedBy: "curator-1", + }); + expect(res.statusCode).toBe(400); + expect(res.json()).toEqual({ error: "manual_needs_second_rater" }); + await app.close(); + }); + + it("accepts manual-curation with two raters, rubric scores, and 3+ evidence entries", async () => { + const sourceId = await createTestSource("rating-manual-ok"); + const app = buildApp(); + const res = await postRating(app, sourceId, { + spectrum: "IN", + biasRating: "center", + factuality: "high", + confidence: "high", + method: "manual-curation", + ratedBy: "curator-1", + secondRater: "curator-2", + rubricScores: { d1: 3, d2: 4 }, + evidence: [{ url: "a" }, { url: "b" }, { url: "c" }], + }); + expect(res.statusCode).toBe(201); + await app.close(); + }); + + it("returns 404 for a nonexistent source", async () => { + const app = buildApp(); + const res = await postRating(app, "00000000-0000-0000-0000-000000000000", { + factuality: "high", + method: "internal-classifier-v1", + ratedBy: "curator-1", + }); + expect(res.statusCode).toBe(404); + await app.close(); + }); +}); diff --git a/apps/api/test/redis-helpers.ts b/apps/api/test/redis-helpers.ts new file mode 100644 index 0000000..699141e --- /dev/null +++ b/apps/api/test/redis-helpers.ts @@ -0,0 +1,33 @@ +// A fresh worker process's Redis client (enableOfflineQueue: false, src/redis.ts) can still be +// mid-connect when a test's first command fires — not just the very first call; it can take +// longer than a short test's whole runtime. Tests that need a command to actually reach Redis +// (not just tolerate the app's own fail-open/fail-safe fallback, which rate-limit/page-cache +// call sites already handle) should wait for readiness via these helpers. +import { getRedis } from "../src/redis.js"; + +async function waitForReady(): Promise { + const redis = getRedis(); + if (redis.status === "ready") return; + await new Promise((resolve, reject) => { + const onReady = () => { + redis.off("error", onError); + resolve(); + }; + const onError = (err: Error) => { + redis.off("ready", onReady); + reject(err); + }; + redis.once("ready", onReady); + redis.once("error", onError); + }); +} + +export async function setTestCache(key: string, value: string, ttlSeconds: number): Promise { + await waitForReady(); + await getRedis().set(key, value, "EX", ttlSeconds); +} + +export async function delTestCache(key: string): Promise { + await waitForReady(); + await getRedis().del(key); +} diff --git a/apps/api/test/saved.test.ts b/apps/api/test/saved.test.ts new file mode 100644 index 0000000..f7f1f91 --- /dev/null +++ b/apps/api/test/saved.test.ts @@ -0,0 +1,114 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { buildApp, WEB_ORIGIN } from "../src/app.js"; +import { signAccessToken } from "../src/features/auth/jwt.js"; +import { createTestSource, createTestStory, createTestTopic } from "./feed-helpers.js"; +import { cleanupTestUser, createUser } from "./helpers.js"; + +const EMAIL = "saved-test@mail.example"; + +beforeEach(() => cleanupTestUser(EMAIL)); +afterEach(() => cleanupTestUser(EMAIL)); + +// §9 CSRF: mutating /api/* requests need Origin + JSON content-type too, same as /auth/*. +function withAuth(token: string) { + return { authorization: `Bearer ${token}`, origin: WEB_ORIGIN, "content-type": "application/json" }; +} + +describe("/api/me/saved-sources", () => { + it("rejects an unauthenticated request", async () => { + const app = buildApp(); + const res = await app.inject({ method: "GET", url: "/api/me/saved-sources" }); + expect(res.statusCode).toBe(401); + await app.close(); + }); + + it("saves (idempotently), lists, and unsaves (idempotently)", async () => { + const userId = await createUser(EMAIL, "a-strong-passphrase"); + const token = signAccessToken(userId, 1); + const sourceId = await createTestSource("saved-source"); + const app = buildApp(); + + const putRes = await app.inject({ + method: "PUT", + url: `/api/me/saved-sources/${sourceId}`, + headers: withAuth(token), + payload: {}, + }); + expect(putRes.statusCode).toBe(204); + + // Idempotent: saving again doesn't duplicate or error. + const putAgain = await app.inject({ + method: "PUT", + url: `/api/me/saved-sources/${sourceId}`, + headers: withAuth(token), + payload: {}, + }); + expect(putAgain.statusCode).toBe(204); + + const listRes = await app.inject({ method: "GET", url: "/api/me/saved-sources", headers: withAuth(token) }); + expect(listRes.json().items).toEqual([sourceId]); + + const deleteRes = await app.inject({ + method: "DELETE", + url: `/api/me/saved-sources/${sourceId}`, + headers: withAuth(token), + payload: {}, + }); + expect(deleteRes.statusCode).toBe(204); + + const afterDelete = await app.inject({ method: "GET", url: "/api/me/saved-sources", headers: withAuth(token) }); + expect(afterDelete.json().items).toEqual([]); + + // Idempotent: unsaving something already unsaved is still a clean 204. + const deleteAgain = await app.inject({ + method: "DELETE", + url: `/api/me/saved-sources/${sourceId}`, + headers: withAuth(token), + payload: {}, + }); + expect(deleteAgain.statusCode).toBe(204); + await app.close(); + }); + + it("returns 404 when saving a source that doesn't exist", async () => { + const userId = await createUser(EMAIL, "a-strong-passphrase"); + const token = signAccessToken(userId, 1); + const app = buildApp(); + const res = await app.inject({ + method: "PUT", + url: "/api/me/saved-sources/00000000-0000-0000-0000-000000000000", + headers: withAuth(token), + payload: {}, + }); + expect(res.statusCode).toBe(404); + await app.close(); + }); +}); + +describe("/api/me/saved-stories and /api/me/saved-topics", () => { + it("save and list a story", async () => { + const userId = await createUser(EMAIL, "a-strong-passphrase"); + const token = signAccessToken(userId, 1); + const sourceId = await createTestSource("saved-story-src"); + const { storyId } = await createTestStory(sourceId); + const app = buildApp(); + + await app.inject({ method: "PUT", url: `/api/me/saved-stories/${storyId}`, headers: withAuth(token), payload: {} }); + const res = await app.inject({ method: "GET", url: "/api/me/saved-stories", headers: withAuth(token) }); + expect(res.json().items).toEqual([storyId]); + await app.close(); + }); + + it("save and list a topic", async () => { + const userId = await createUser(EMAIL, "a-strong-passphrase"); + const token = signAccessToken(userId, 1); + const topicId = await createTestTopic("Saved topic test", ["widgets"]); + const app = buildApp(); + + await app.inject({ method: "PUT", url: `/api/me/saved-topics/${topicId}`, headers: withAuth(token), payload: {} }); + const res = await app.inject({ method: "GET", url: "/api/me/saved-topics", headers: withAuth(token) }); + expect(res.json().items).toEqual([topicId]); + await app.close(); + }); +}); diff --git a/apps/api/test/signup.test.ts b/apps/api/test/signup.test.ts index 8bff0d6..7f34904 100644 --- a/apps/api/test/signup.test.ts +++ b/apps/api/test/signup.test.ts @@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { buildApp, WEB_ORIGIN } from "../src/app.js"; import { getPool } from "../src/db.js"; -import { getRedis } from "../src/redis.js"; +import { delTestCache } from "./redis-helpers.js"; // *.example is RFC 2606 reserved; mirrors apps/ingestion's test-fixture convention. const EMAIL = "signup-test@mail.example"; @@ -14,13 +14,8 @@ async function cleanup(): Promise { ); await getPool().query("DELETE FROM users WHERE lower(email) = lower($1)", [EMAIL]); // Tests reuse the same email across cases; the signup rate limit (3/hour, §9) is keyed on - // it too, so it needs the same kind of reset as the DB rows above. Best-effort: the very - // first call can race the client's initial connection (nothing to clean up yet anyway). - try { - await getRedis().del(`ratelimit:signup:email:${EMAIL}:3600`); - } catch { - // ignore - } + // it too, so it needs the same kind of reset as the DB rows above. + await delTestCache(`ratelimit:signup:email:${EMAIL}:3600`); } beforeEach(cleanup); diff --git a/apps/api/test/story.test.ts b/apps/api/test/story.test.ts new file mode 100644 index 0000000..3de6a00 --- /dev/null +++ b/apps/api/test/story.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from "vitest"; + +import { buildApp } from "../src/app.js"; +import { + addCoverage, + createTestSource, + createTestStory, + insertLineage, + rateSource, + tombstoneStory, +} from "./feed-helpers.js"; +import { delTestCache, setTestCache } from "./redis-helpers.js"; + +describe("GET /api/public/stories/:id", () => { + it("groups articles by source, dedupes by content_hash, excludes hidden, and labels not_rated", async () => { + const sourceA = await createTestSource("story-a"); + const sourceB = await createTestSource("story-hidden"); + await rateSource(sourceA, { biasRating: "left", confidence: "high" }); + // sourceB left deliberately unrated. + + const { storyId } = await createTestStory(sourceA, { title: "Primary headline" }); + // A second, duplicate-content article from the same source: should collapse into moreCount. + await addCoverage(storyId, sourceA, { contentHash: "shared-hash", title: "Duplicate copy" }); + await addCoverage(storyId, sourceA, { contentHash: "shared-hash", title: "Another duplicate copy" }); + // A hidden article from a second source: must not appear at all. + await addCoverage(storyId, sourceB, { hiddenAt: new Date() }); + + await delTestCache(`page:story:${storyId}`); + const app = buildApp(); + const res = await app.inject({ method: "GET", url: `/api/public/stories/${storyId}` }); + expect(res.statusCode).toBe(200); + + const body = res.json(); + expect(body.status).toBe("active"); + expect(body.redirectTo).toBeNull(); + + // headline article (unique hash) + the two duplicates (shared hash, collapsed to one card) + // both live under sourceA's single left-column card. + expect(body.biasColumns.left).toHaveLength(1); + expect(body.biasColumns.left[0].sourceId).toBe(sourceA); + expect(body.biasColumns.left[0].moreCount).toBe(1); // 2 distinct hashes for sourceA → 1 extra + + // sourceB's only article is hidden, so it contributes nothing — not even a not_rated card. + expect(body.biasColumns.notRated).toHaveLength(0); + await app.close(); + }); + + it("returns 404 for a story that doesn't exist", async () => { + const app = buildApp(); + const res = await app.inject({ method: "GET", url: "/api/public/stories/00000000-0000-0000-0000-000000000000" }); + expect(res.statusCode).toBe(404); + expect(res.json()).toEqual({ error: "not_found" }); + await app.close(); + }); + + it("a tombstoned story returns redirectTo and empty bias columns", async () => { + const sourceA = await createTestSource("redirect-old"); + const sourceB = await createTestSource("redirect-new"); + const { storyId: oldStoryId } = await createTestStory(sourceA); + const { storyId: newStoryId } = await createTestStory(sourceB, { title: "Successor story" }); + await tombstoneStory(oldStoryId, newStoryId); + + await delTestCache(`page:story:${oldStoryId}`); + const app = buildApp(); + const res = await app.inject({ method: "GET", url: `/api/public/stories/${oldStoryId}` }); + expect(res.statusCode).toBe(200); + + const body = res.json(); + expect(body.status).toBe("tombstoned"); + expect(body.redirectTo).toEqual({ id: newStoryId, title: "Successor story" }); + expect(body.biasColumns.left).toEqual([]); + expect(body.biasColumns.notRated).toEqual([]); + await app.close(); + }); + + it("surfaces split notices, excluding the primary redirect target", async () => { + const sourceA = await createTestSource("split-origin"); + const sourceB = await createTestSource("split-secondary"); + const { storyId: originId } = await createTestStory(sourceA); + const { storyId: secondaryId } = await createTestStory(sourceB, { title: "Secondary split target" }); + await insertLineage(originId, secondaryId, 4); + + await delTestCache(`page:story:${originId}`); + const app = buildApp(); + const res = await app.inject({ method: "GET", url: `/api/public/stories/${originId}` }); + expect(res.statusCode).toBe(200); + + const body = res.json(); + expect(body.splitNotices).toEqual([{ storyId: secondaryId, title: "Secondary split target", articleCount: 4 }]); + await app.close(); + }); + + it("serves the cached body on a repeat request", async () => { + const sourceA = await createTestSource("story-cache"); + const { storyId } = await createTestStory(sourceA); + const fake = { id: storyId, marker: "from-cache" }; + + const app = buildApp(); + // Warm real data first so we know a genuine miss would differ from `fake`. + await app.inject({ method: "GET", url: `/api/public/stories/${storyId}` }); + + await setTestCache(`page:story:${storyId}`, JSON.stringify(fake), 60); + + const res = await app.inject({ method: "GET", url: `/api/public/stories/${storyId}` }); + expect(res.json()).toEqual(fake); + await delTestCache(`page:story:${storyId}`); + await app.close(); + }); +}); diff --git a/apps/api/test/topic.test.ts b/apps/api/test/topic.test.ts new file mode 100644 index 0000000..0e0295c --- /dev/null +++ b/apps/api/test/topic.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from "vitest"; + +import { buildApp } from "../src/app.js"; +import { encodeCursor } from "../src/pagination.js"; +import { addCoverage, createTestSource, createTestStory, createTestTopic } from "./feed-helpers.js"; +import { delTestCache } from "./redis-helpers.js"; + +describe("GET /api/public/topics", () => { + it("lists a newly created topic", async () => { + const topicId = await createTestTopic("Elections", ["election"]); + await delTestCache("page:topics:list"); + + const app = buildApp(); + const res = await app.inject({ method: "GET", url: "/api/public/topics" }); + expect(res.statusCode).toBe(200); + const ids = res.json().map((t: { id: string }) => t.id); + expect(ids).toContain(topicId); + await app.close(); + }); +}); + +describe("GET /api/public/topics/:id/feed", () => { + it("matches whole-word keyword hits, not substrings (AI vs AIIMS)", async () => { + const topicId = await createTestTopic("AI coverage", ["AI"]); + const sourceId = await createTestSource("topic-match"); + + const { storyId: matching } = await createTestStory(sourceId, { title: "New AI rules announced" }); + const { storyId: notMatching } = await createTestStory(sourceId, { title: "AIIMS opens new wing" }); + + await delTestCache(`page:topic:${topicId}:first`); + const app = buildApp(); + const res = await app.inject({ method: "GET", url: `/api/public/topics/${topicId}/feed` }); + expect(res.statusCode).toBe(200); + + const ids = res.json().items.map((i: { id: string }) => i.id); + expect(ids).toContain(matching); + expect(ids).not.toContain(notMatching); + await app.close(); + }); + + it("matches against a visible member article's title, not just the headline", async () => { + const topicId = await createTestTopic("Cricket", ["cricket"]); + const sourceA = await createTestSource("topic-headline"); + const sourceB = await createTestSource("topic-member"); + const { storyId } = await createTestStory(sourceA, { title: "Unrelated headline" }); + await addCoverage(storyId, sourceB, { title: "Cricket match ends in a draw" }); + + await delTestCache(`page:topic:${topicId}:first`); + const app = buildApp(); + const res = await app.inject({ method: "GET", url: `/api/public/topics/${topicId}/feed` }); + const ids = res.json().items.map((i: { id: string }) => i.id); + expect(ids).toContain(storyId); + await app.close(); + }); + + it("excludes a match whose only matching article is hidden", async () => { + const topicId = await createTestTopic("Budget", ["budget"]); + const sourceA = await createTestSource("topic-hidden-headline"); + const sourceB = await createTestSource("topic-hidden-member"); + const { storyId } = await createTestStory(sourceA, { title: "Unrelated headline" }); + await addCoverage(storyId, sourceB, { title: "Union budget announced today", hiddenAt: new Date() }); + + await delTestCache(`page:topic:${topicId}:first`); + const app = buildApp(); + const res = await app.inject({ method: "GET", url: `/api/public/topics/${topicId}/feed` }); + const ids = res.json().items.map((i: { id: string }) => i.id); + expect(ids).not.toContain(storyId); + await app.close(); + }); + + it("excludes stories whose coverage is older than the 14-day window", async () => { + const topicId = await createTestTopic("Monsoon", ["monsoon"]); + const sourceId = await createTestSource("topic-old"); + const { storyId } = await createTestStory(sourceId, { + title: "Monsoon arrives early this year", + lastCoverageAt: new Date(Date.now() - 20 * 24 * 60 * 60 * 1000), + }); + + await delTestCache(`page:topic:${topicId}:first`); + const app = buildApp(); + const res = await app.inject({ method: "GET", url: `/api/public/topics/${topicId}/feed` }); + const ids = res.json().items.map((i: { id: string }) => i.id); + expect(ids).not.toContain(storyId); + await app.close(); + }); + + it("returns 404 for a topic that doesn't exist", async () => { + const app = buildApp(); + const res = await app.inject({ method: "GET", url: "/api/public/topics/00000000-0000-0000-0000-000000000000/feed" }); + expect(res.statusCode).toBe(404); + await app.close(); + }); + + it("rejects a cursor minted for a different topic (feed binding)", async () => { + const topicA = await createTestTopic("Topic A", ["alpha"]); + const topicB = await createTestTopic("Topic B", ["beta"]); + await delTestCache(`page:topic:${topicA}:first`); + + const app = buildApp(); + const firstRes = await app.inject({ method: "GET", url: `/api/public/topics/${topicA}/feed` }); + // No items yet for a brand-new topic means no next_cursor to steal — build one manually + // bound to topicA, then try to use it against topicB. + const foreignCursor = encodeCursor({ + v: 1, + feed: `topic:${topicA}`, + as_of: firstRes.json().asOf, + c: new Date().toISOString(), + id: "x", + }); + + const res = await app.inject({ + method: "GET", + url: `/api/public/topics/${topicB}/feed?cursor=${encodeURIComponent(foreignCursor)}`, + }); + expect(res.statusCode).toBe(400); + expect(res.json()).toEqual({ error: "invalid_cursor" }); + await app.close(); + }); +}); diff --git a/infra/migrations/0002_blindspot_functions.sql b/infra/migrations/0002_blindspot_functions.sql new file mode 100644 index 0000000..75ff951 --- /dev/null +++ b/infra/migrations/0002_blindspot_functions.sql @@ -0,0 +1,29 @@ +-- PRODUCT.md §4.6. Not part of 0001 because the migration generator pulled schema only from +-- ARCHITECTURE.md §4 (Data Model) — these two functions live in PRODUCT.md instead, so they +-- were missed. Copied verbatim; must match exactly (CLAUDE.md: kept honest by the same test +-- as the Python implementation in validation/unit, once that exists). + +-- One-sided Wilson upper bound. Must match validation/product/blindspot.py exactly. +CREATE FUNCTION wilson_upper(k int, n int, z float8 DEFAULT 1.645) RETURNS float8 +LANGUAGE sql IMMUTABLE STRICT AS $$ + SELECT CASE WHEN n <= 0 THEN 1.0 ELSE + LEAST(1.0, GREATEST(k::float8 / n, -- clamp: exact value at k = n is 1 + ((k::float8 / n) + z * z / (2 * n) + + z * sqrt((k::float8 / n) * (1 - k::float8 / n) / n + z * z / (4.0 * n * n))) + / (1 + z * z / n))) END +$$; + +-- 'R' = little coverage from right-leaning sources, 'L' = from left-leaning, NULL = no flag. +CREATE FUNCTION blindspot_side(k_left int, k_center int, k_right int, + p_left float8, p_center float8, p_right float8, + n_min int DEFAULT 6, materiality float8 DEFAULT 0.5, z float8 DEFAULT 1.645) +RETURNS text LANGUAGE sql IMMUTABLE STRICT AS $$ + SELECT CASE + WHEN n < n_min THEN NULL + WHEN k_right::float8 / n <= materiality * p_right AND wilson_upper(k_right, n, z) < p_right + AND k_left::float8 / n >= p_left THEN 'R' + WHEN k_left::float8 / n <= materiality * p_left AND wilson_upper(k_left, n, z) < p_left + AND k_right::float8 / n >= p_right THEN 'L' + END + FROM (SELECT k_left + k_center + k_right AS n) t +$$; From 0d87c46030fc25c9d9c0fee3bc618d996531d636 Mon Sep 17 00:00:00 2001 From: saran Date: Sat, 10 Oct 2026 23:32:30 +0530 Subject: [PATCH 07/12] openapi integrations and completed all endpoints for basic news curations and bias detection workflow --- .spectral.yaml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 .spectral.yaml diff --git a/.spectral.yaml b/.spectral.yaml new file mode 100644 index 0000000..645982b --- /dev/null +++ b/.spectral.yaml @@ -0,0 +1,14 @@ +extends: + - spectral:oas + +rules: + # ARCHITECTURE §10/§12: every timestamp (*At) and count (*Count) field needs a + # `description` — this is what keeps docs/openapi.yaml's pinned meanings from drifting. + timestamp-count-fields-need-description: + description: Every timestamp (*At) and count (*Count) field must have a description. + message: "'{{property}}' is a timestamp/count field and must have a `description` (ARCHITECTURE §10 pinned meanings)." + severity: error + given: "$.components.schemas.*.properties[?(@property.match(/(At|Count)$/))]" + then: + field: description + function: truthy From 681918bb66319628d8134e20c669ca546e6e382f Mon Sep 17 00:00:00 2001 From: saran Date: Sun, 11 Oct 2026 10:51:25 +0530 Subject: [PATCH 08/12] integrated resend api mailer to the system --- apps/api/src/mailer/index.ts | 15 ++++++++-- apps/api/src/mailer/transport.ts | 22 +++++++++++++++ apps/api/test/resend-transport.test.ts | 39 ++++++++++++++++++++++++++ infra/docker-compose.yml | 2 ++ 4 files changed, 76 insertions(+), 2 deletions(-) create mode 100644 apps/api/test/resend-transport.test.ts diff --git a/apps/api/src/mailer/index.ts b/apps/api/src/mailer/index.ts index 9d79590..e16b142 100644 --- a/apps/api/src/mailer/index.ts +++ b/apps/api/src/mailer/index.ts @@ -4,10 +4,21 @@ import { getPool } from "../db.js"; import { WEB_ORIGIN } from "../config.js"; import { processOnce } from "./service.js"; -import { ConsoleMailTransport } from "./transport.js"; +import { ConsoleMailTransport, ResendMailTransport, type MailTransport } from "./transport.js"; const TICK_MS = 10_000; -const transport = new ConsoleMailTransport(); + +// Dormant until real creds exist (complexity budget §1): no RESEND_API_KEY means dev/test, +// so mail just goes to the console instead of failing to start. +function buildTransport(): MailTransport { + const apiKey = process.env.RESEND_API_KEY; + if (apiKey === undefined) return new ConsoleMailTransport(); + const from = process.env.MAIL_FROM; + if (from === undefined) throw new Error("mailer: RESEND_API_KEY is set but MAIL_FROM is not"); + return new ResendMailTransport(apiKey, from); +} + +const transport = buildTransport(); async function drain(): Promise { while (await processOnce(getPool(), transport, WEB_ORIGIN)) { diff --git a/apps/api/src/mailer/transport.ts b/apps/api/src/mailer/transport.ts index 4f16fe6..1e522ec 100644 --- a/apps/api/src/mailer/transport.ts +++ b/apps/api/src/mailer/transport.ts @@ -19,3 +19,25 @@ export class ConsoleMailTransport implements MailTransport { ); } } + +// Resend over plain fetch — no SDK dependency for one POST call. Picked over Postmark/SES +// because its REST API takes an `Idempotency-Key` header directly, matching §9's "provider's +// idempotency key = outbox id" without any extra dedup bookkeeping on our side. +export class ResendMailTransport implements MailTransport { + constructor(private readonly apiKey: string, private readonly from: string) {} + + async send(message: MailMessage): Promise { + const response = await fetch("https://api.resend.com/emails", { + method: "POST", + headers: { + Authorization: `Bearer ${this.apiKey}`, + "Content-Type": "application/json", + "Idempotency-Key": message.idempotencyKey, + }, + body: JSON.stringify({ from: this.from, to: message.to, subject: message.subject, text: message.body }), + }); + if (!response.ok) { + throw new Error(`resend: ${response.status} ${await response.text()}`); + } + } +} diff --git a/apps/api/test/resend-transport.test.ts b/apps/api/test/resend-transport.test.ts new file mode 100644 index 0000000..9f1ab40 --- /dev/null +++ b/apps/api/test/resend-transport.test.ts @@ -0,0 +1,39 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { ResendMailTransport } from "../src/mailer/transport.js"; + +afterEach(() => vi.unstubAllGlobals()); + +describe("ResendMailTransport", () => { + it("posts to Resend with the idempotency key and from address", async () => { + const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 200 })); + vi.stubGlobal("fetch", fetchMock); + + const transport = new ResendMailTransport("test-key", "noreply@newstack.example"); + await transport.send({ to: "user@mail.example", subject: "Subject", body: "Body", idempotencyKey: "outbox-1" }); + + expect(fetchMock).toHaveBeenCalledOnce(); + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe("https://api.resend.com/emails"); + expect(init.method).toBe("POST"); + expect(init.headers).toMatchObject({ + Authorization: "Bearer test-key", + "Idempotency-Key": "outbox-1", + }); + expect(JSON.parse(init.body)).toEqual({ + from: "noreply@newstack.example", + to: "user@mail.example", + subject: "Subject", + text: "Body", + }); + }); + + it("throws on a non-2xx response so the mailer's retry/backoff picks it up", async () => { + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response("bad request", { status: 400 }))); + + const transport = new ResendMailTransport("test-key", "noreply@newstack.example"); + await expect( + transport.send({ to: "user@mail.example", subject: "s", body: "b", idempotencyKey: "outbox-2" }) + ).rejects.toThrow("resend: 400 bad request"); + }); +}); diff --git a/infra/docker-compose.yml b/infra/docker-compose.yml index fdb4b24..af13cad 100644 --- a/infra/docker-compose.yml +++ b/infra/docker-compose.yml @@ -60,6 +60,8 @@ services: environment: DATABASE_URL: postgresql://newstack:newstack@db:5432/newstack WEB_ORIGIN: http://localhost:5173 + # RESEND_API_KEY / MAIL_FROM: unset in dev on purpose — the mailer falls back to the + # console transport (src/mailer/index.ts). Set both in the real environment to send mail. depends_on: db: condition: service_healthy From fbc51e670b3eeeb6b0174d79b8e2708acc8e4a1c Mon Sep 17 00:00:00 2001 From: saran Date: Sun, 11 Oct 2026 11:06:52 +0530 Subject: [PATCH 09/12] fixed a gap found in feed helper flow --- apps/api/test/feed-helpers.ts | 55 ++++++++++++++++------------ apps/ingestion/tests/test_sweeper.py | 21 ++++++----- 2 files changed, 44 insertions(+), 32 deletions(-) diff --git a/apps/api/test/feed-helpers.ts b/apps/api/test/feed-helpers.ts index a01aa42..7ced8dc 100644 --- a/apps/api/test/feed-helpers.ts +++ b/apps/api/test/feed-helpers.ts @@ -10,7 +10,8 @@ // (§11: "Content: sources, articles, stories, ratings — indefinite" retention) — fixtures are // real, permanent rows, so every call below generates its own unique domain/title/url instead // of trying to reuse or delete anything. -import { getPool } from "../src/db.js"; +import type { Pool, PoolClient } from "pg"; +import { getPool, withTransaction } from "../src/db.js"; const ZERO_VECTOR = `[${Array(384).fill(0).join(",")}]`; let counter = 0; @@ -55,13 +56,14 @@ async function insertArticle( clusterVersion?: string; title?: string; contentHash?: string; - } + }, + queryable: Pool | PoolClient = getPool() ): Promise { const suffix = uniqueSuffix(); const embedded = opts.storyId !== null; // I1: embedding/embedding_model/embedded_content_hash are null together const hiddenAt = opts.hiddenAt ?? null; const contentHash = opts.contentHash ?? `hash-${suffix}`; - const result = await getPool().query<{ id: string }>( + const result = await queryable.query<{ id: string }>( `INSERT INTO articles ( source_id, title, url, canonical_url, canonicalization_version, content_hash, effective_at, first_scraped_at, embedding, embedding_model, embedded_content_hash, @@ -92,29 +94,36 @@ export async function createTestStory( ): Promise<{ storyId: string; headlineArticleId: string }> { const clusterVersion = opts.clusterVersion ?? "test-v1"; const effectiveAt = opts.lastCoverageAt ?? new Date(); - const headlineArticleId = await insertArticle(sourceId, { storyId: null, effectiveAt }); - const story = await getPool().query<{ id: string }>( - `INSERT INTO stories ( - title, summary, headline_article_id, representative_embedding, representative_embedding_model, - cluster_version, status, first_seen_at, event_last_updated_at, last_coverage_at - ) VALUES ($1, 'test summary', $2, $3::vector, 'test-model', $4, 'active', $5, $5, $5) - RETURNING id`, - [opts.title ?? `Test story ${uniqueSuffix()}`, headlineArticleId, ZERO_VECTOR, clusterVersion, effectiveAt] - ); - const storyId = story.rows[0].id; + // All three writes run in one transaction: a throw between steps (assertion failure, + // timeout) must not leave an orphaned article — stories can never be hard-deleted, so an + // article stuck with story_id/embedding null forever would permanently pollute the + // ingestion sweeper's "needs embedding" query across the whole shared dev DB. + return withTransaction(getPool(), async (client) => { + const headlineArticleId = await insertArticle(sourceId, { storyId: null, effectiveAt }, client); - // Must flip story_id and embedding/embedding_model/embedded_content_hash together — I1 - // requires them null (or not) in lockstep, and before this the headline article had none. - await getPool().query( - `UPDATE articles - SET story_id = $1, cluster_version = $2, embedding = $3::vector, - embedding_model = 'test-model', embedded_content_hash = content_hash - WHERE id = $4`, - [storyId, clusterVersion, ZERO_VECTOR, headlineArticleId] - ); + const story = await client.query<{ id: string }>( + `INSERT INTO stories ( + title, summary, headline_article_id, representative_embedding, representative_embedding_model, + cluster_version, status, first_seen_at, event_last_updated_at, last_coverage_at + ) VALUES ($1, 'test summary', $2, $3::vector, 'test-model', $4, 'active', $5, $5, $5) + RETURNING id`, + [opts.title ?? `Test story ${uniqueSuffix()}`, headlineArticleId, ZERO_VECTOR, clusterVersion, effectiveAt] + ); + const storyId = story.rows[0].id; + + // Must flip story_id and embedding/embedding_model/embedded_content_hash together — I1 + // requires them null (or not) in lockstep, and before this the headline article had none. + await client.query( + `UPDATE articles + SET story_id = $1, cluster_version = $2, embedding = $3::vector, + embedding_model = 'test-model', embedded_content_hash = content_hash + WHERE id = $4`, + [storyId, clusterVersion, ZERO_VECTOR, headlineArticleId] + ); - return { storyId, headlineArticleId }; + return { storyId, headlineArticleId }; + }); } // Adds another source's coverage to an existing story (for multi-source bias-bar tests). diff --git a/apps/ingestion/tests/test_sweeper.py b/apps/ingestion/tests/test_sweeper.py index 1a71e7c..8ee10e9 100644 --- a/apps/ingestion/tests/test_sweeper.py +++ b/apps/ingestion/tests/test_sweeper.py @@ -46,24 +46,27 @@ def test_sweep_picks_never_attempted_article(source_id, monkeypatch): monkeypatch.setattr(sweeper, "process_article", lambda conn, **kw: calls.append(kw["article_id"]) or "CLUSTERED") with psycopg.connect(DSN) as conn: stats = sweeper.sweep(conn, denylist_patterns=[]) - assert calls == [eligible] - assert stats == {"sweeper_picked": 1, "sweeper_succeeded": 1} + # sweep() scans the whole table, not just this fixture's source — a shared dev DB can + # carry other eligible rows (e.g. other suites' fixtures), so check membership, not + # exact equality. stats totals legitimately include whatever else was eligible. + assert eligible in calls + assert stats["sweeper_picked"] == len(calls) + assert stats["sweeper_succeeded"] == len(calls) def test_sweep_skips_article_still_in_backoff_window(source_id, monkeypatch): - _pending_article(source_id, "x.com/b", embed_attempts=1, last_attempt_minutes_ago=1) # 2^1=2min backoff, 1<2 + pending = _pending_article(source_id, "x.com/b", embed_attempts=1, last_attempt_minutes_ago=1) # 2^1=2min, 1<2 calls = [] monkeypatch.setattr(sweeper, "process_article", lambda conn, **kw: calls.append(kw["article_id"]) or "CLUSTERED") with psycopg.connect(DSN) as conn: - stats = sweeper.sweep(conn, denylist_patterns=[]) - assert calls == [] - assert stats["sweeper_picked"] == 0 + sweeper.sweep(conn, denylist_patterns=[]) + assert pending not in calls def test_sweep_skips_gave_up_article(source_id, monkeypatch): - _pending_article(source_id, "x.com/c", embed_attempts=5) # max_embed_attempts reached + gave_up = _pending_article(source_id, "x.com/c", embed_attempts=5) # max_embed_attempts reached calls = [] monkeypatch.setattr(sweeper, "process_article", lambda conn, **kw: calls.append(kw["article_id"]) or "CLUSTERED") with psycopg.connect(DSN) as conn: - stats = sweeper.sweep(conn, denylist_patterns=[]) - assert calls == [] + sweeper.sweep(conn, denylist_patterns=[]) + assert gave_up not in calls From 924258ce9b9d1383efe925822a45fc18f75fb4f0 Mon Sep 17 00:00:00 2001 From: saran Date: Sun, 11 Oct 2026 14:28:33 +0530 Subject: [PATCH 10/12] added additional apis for testing and start build on frontend --- apps/api/src/app.ts | 21 + apps/api/src/features/blindspot/routes.ts | 25 + apps/api/src/features/blindspot/service.ts | 7 +- apps/api/src/features/source/repo.ts | 18 + apps/api/src/features/source/routes.ts | 32 + apps/api/src/features/source/service.ts | 30 + apps/api/test/blindspot.test.ts | 30 + apps/api/test/source.test.ts | 34 + apps/web/.gitignore | 24 + apps/web/.oxlintrc.json | 8 + apps/web/README.md | 32 + apps/web/index.html | 13 + apps/web/package-lock.json | 3147 +++++++++++++++++ apps/web/package.json | 34 + apps/web/public/favicon.svg | 1 + apps/web/public/icons.svg | 24 + apps/web/src/App.tsx | 13 + apps/web/src/app/RootLayout.tsx | 43 + apps/web/src/app/router.tsx | 15 + apps/web/src/components/QueryStates.tsx | 24 + apps/web/src/components/RequireAuth.tsx | 13 + apps/web/src/features/auth/api.ts | 26 + .../features/auth/hooks/useAuthMutations.ts | 25 + apps/web/src/features/auth/index.ts | 1 + .../web/src/features/auth/pages/LoginPage.tsx | 53 + .../features/auth/pages/ResetConfirmPage.tsx | 66 + .../features/auth/pages/ResetPage.test.tsx | 22 + .../web/src/features/auth/pages/ResetPage.tsx | 10 + .../features/auth/pages/ResetRequestPage.tsx | 38 + .../src/features/auth/pages/SignupPage.tsx | 61 + .../src/features/auth/pages/VerifyPage.tsx | 33 + apps/web/src/features/auth/routes.tsx | 13 + apps/web/src/features/auth/types.ts | 3 + .../src/features/auth/utils/hashToken.test.ts | 14 + apps/web/src/features/auth/utils/hashToken.ts | 6 + apps/web/src/features/curator/api.ts | 31 + .../curator/components/CuratorKeyGate.tsx | 51 + .../curator/hooks/useCuratorMutations.ts | 48 + apps/web/src/features/curator/index.ts | 1 + .../features/curator/lib/curatorFetch.test.ts | 47 + .../src/features/curator/lib/curatorFetch.ts | 44 + .../curator/pages/CuratorBlindspotPage.tsx | 52 + .../features/curator/pages/CuratorLayout.tsx | 35 + .../curator/pages/CuratorModerationPage.tsx | 111 + .../curator/pages/CuratorSourcesPage.tsx | 140 + .../curator/pages/CuratorTopicsPage.tsx | 43 + apps/web/src/features/curator/routes.tsx | 21 + apps/web/src/features/curator/store.ts | 16 + apps/web/src/features/curator/types.ts | 56 + apps/web/src/features/feed/api.ts | 11 + .../features/feed/components/BiasBar.test.tsx | 55 + .../src/features/feed/components/BiasBar.tsx | 53 + .../feed/components/BiasBarLegend.tsx | 23 + .../features/feed/components/BiasColumn.tsx | 36 + .../features/feed/components/FeedItemCard.tsx | 20 + .../src/features/feed/hooks/useHomeFeed.ts | 12 + .../src/features/feed/hooks/useStoryDetail.ts | 10 + apps/web/src/features/feed/index.ts | 4 + .../src/features/feed/pages/HomeFeedPage.tsx | 41 + .../features/feed/pages/StoryDetailPage.tsx | 80 + apps/web/src/features/feed/routes.tsx | 9 + apps/web/src/features/feed/types.ts | 58 + apps/web/src/features/saved/api.ts | 28 + .../components/SaveToggleButton.test.tsx | 29 + .../saved/components/SaveToggleButton.tsx | 44 + apps/web/src/features/saved/hooks/useSaved.ts | 68 + apps/web/src/features/saved/index.ts | 2 + .../src/features/saved/pages/SavedLayout.tsx | 31 + .../features/saved/pages/SavedSourcesTab.tsx | 30 + .../features/saved/pages/SavedStoriesTab.tsx | 38 + .../features/saved/pages/SavedTopicsTab.tsx | 28 + apps/web/src/features/saved/routes.tsx | 24 + apps/web/src/features/saved/types.ts | 22 + apps/web/src/features/topics/api.ts | 12 + .../src/features/topics/hooks/useTopics.ts | 16 + apps/web/src/features/topics/index.ts | 2 + .../features/topics/pages/TopicFeedPage.tsx | 34 + .../features/topics/pages/TopicListPage.tsx | 29 + apps/web/src/features/topics/routes.tsx | 9 + apps/web/src/features/topics/types.ts | 5 + apps/web/src/index.css | 32 + apps/web/src/lib/apiClient.test.ts | 113 + apps/web/src/lib/apiClient.ts | 108 + apps/web/src/lib/authStore.ts | 30 + apps/web/src/lib/jwt.test.ts | 20 + apps/web/src/lib/jwt.ts | 14 + apps/web/src/lib/queryClient.ts | 10 + apps/web/src/main.tsx | 16 + apps/web/src/test/renderWithProviders.tsx | 13 + apps/web/src/test/setup.ts | 1 + apps/web/tsconfig.app.json | 26 + apps/web/tsconfig.json | 7 + apps/web/tsconfig.node.json | 23 + apps/web/vite.config.ts | 13 + 94 files changed, 5941 insertions(+), 3 deletions(-) create mode 100644 apps/api/src/features/blindspot/routes.ts create mode 100644 apps/api/src/features/source/repo.ts create mode 100644 apps/api/src/features/source/routes.ts create mode 100644 apps/api/src/features/source/service.ts create mode 100644 apps/api/test/source.test.ts create mode 100644 apps/web/.gitignore create mode 100644 apps/web/.oxlintrc.json create mode 100644 apps/web/README.md create mode 100644 apps/web/index.html create mode 100644 apps/web/package-lock.json create mode 100644 apps/web/package.json create mode 100644 apps/web/public/favicon.svg create mode 100644 apps/web/public/icons.svg create mode 100644 apps/web/src/App.tsx create mode 100644 apps/web/src/app/RootLayout.tsx create mode 100644 apps/web/src/app/router.tsx create mode 100644 apps/web/src/components/QueryStates.tsx create mode 100644 apps/web/src/components/RequireAuth.tsx create mode 100644 apps/web/src/features/auth/api.ts create mode 100644 apps/web/src/features/auth/hooks/useAuthMutations.ts create mode 100644 apps/web/src/features/auth/index.ts create mode 100644 apps/web/src/features/auth/pages/LoginPage.tsx create mode 100644 apps/web/src/features/auth/pages/ResetConfirmPage.tsx create mode 100644 apps/web/src/features/auth/pages/ResetPage.test.tsx create mode 100644 apps/web/src/features/auth/pages/ResetPage.tsx create mode 100644 apps/web/src/features/auth/pages/ResetRequestPage.tsx create mode 100644 apps/web/src/features/auth/pages/SignupPage.tsx create mode 100644 apps/web/src/features/auth/pages/VerifyPage.tsx create mode 100644 apps/web/src/features/auth/routes.tsx create mode 100644 apps/web/src/features/auth/types.ts create mode 100644 apps/web/src/features/auth/utils/hashToken.test.ts create mode 100644 apps/web/src/features/auth/utils/hashToken.ts create mode 100644 apps/web/src/features/curator/api.ts create mode 100644 apps/web/src/features/curator/components/CuratorKeyGate.tsx create mode 100644 apps/web/src/features/curator/hooks/useCuratorMutations.ts create mode 100644 apps/web/src/features/curator/index.ts create mode 100644 apps/web/src/features/curator/lib/curatorFetch.test.ts create mode 100644 apps/web/src/features/curator/lib/curatorFetch.ts create mode 100644 apps/web/src/features/curator/pages/CuratorBlindspotPage.tsx create mode 100644 apps/web/src/features/curator/pages/CuratorLayout.tsx create mode 100644 apps/web/src/features/curator/pages/CuratorModerationPage.tsx create mode 100644 apps/web/src/features/curator/pages/CuratorSourcesPage.tsx create mode 100644 apps/web/src/features/curator/pages/CuratorTopicsPage.tsx create mode 100644 apps/web/src/features/curator/routes.tsx create mode 100644 apps/web/src/features/curator/store.ts create mode 100644 apps/web/src/features/curator/types.ts create mode 100644 apps/web/src/features/feed/api.ts create mode 100644 apps/web/src/features/feed/components/BiasBar.test.tsx create mode 100644 apps/web/src/features/feed/components/BiasBar.tsx create mode 100644 apps/web/src/features/feed/components/BiasBarLegend.tsx create mode 100644 apps/web/src/features/feed/components/BiasColumn.tsx create mode 100644 apps/web/src/features/feed/components/FeedItemCard.tsx create mode 100644 apps/web/src/features/feed/hooks/useHomeFeed.ts create mode 100644 apps/web/src/features/feed/hooks/useStoryDetail.ts create mode 100644 apps/web/src/features/feed/index.ts create mode 100644 apps/web/src/features/feed/pages/HomeFeedPage.tsx create mode 100644 apps/web/src/features/feed/pages/StoryDetailPage.tsx create mode 100644 apps/web/src/features/feed/routes.tsx create mode 100644 apps/web/src/features/feed/types.ts create mode 100644 apps/web/src/features/saved/api.ts create mode 100644 apps/web/src/features/saved/components/SaveToggleButton.test.tsx create mode 100644 apps/web/src/features/saved/components/SaveToggleButton.tsx create mode 100644 apps/web/src/features/saved/hooks/useSaved.ts create mode 100644 apps/web/src/features/saved/index.ts create mode 100644 apps/web/src/features/saved/pages/SavedLayout.tsx create mode 100644 apps/web/src/features/saved/pages/SavedSourcesTab.tsx create mode 100644 apps/web/src/features/saved/pages/SavedStoriesTab.tsx create mode 100644 apps/web/src/features/saved/pages/SavedTopicsTab.tsx create mode 100644 apps/web/src/features/saved/routes.tsx create mode 100644 apps/web/src/features/saved/types.ts create mode 100644 apps/web/src/features/topics/api.ts create mode 100644 apps/web/src/features/topics/hooks/useTopics.ts create mode 100644 apps/web/src/features/topics/index.ts create mode 100644 apps/web/src/features/topics/pages/TopicFeedPage.tsx create mode 100644 apps/web/src/features/topics/pages/TopicListPage.tsx create mode 100644 apps/web/src/features/topics/routes.tsx create mode 100644 apps/web/src/features/topics/types.ts create mode 100644 apps/web/src/index.css create mode 100644 apps/web/src/lib/apiClient.test.ts create mode 100644 apps/web/src/lib/apiClient.ts create mode 100644 apps/web/src/lib/authStore.ts create mode 100644 apps/web/src/lib/jwt.test.ts create mode 100644 apps/web/src/lib/jwt.ts create mode 100644 apps/web/src/lib/queryClient.ts create mode 100644 apps/web/src/main.tsx create mode 100644 apps/web/src/test/renderWithProviders.tsx create mode 100644 apps/web/src/test/setup.ts create mode 100644 apps/web/tsconfig.app.json create mode 100644 apps/web/tsconfig.json create mode 100644 apps/web/tsconfig.node.json create mode 100644 apps/web/vite.config.ts diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 4831878..4759045 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -4,11 +4,13 @@ import Fastify, { type FastifyInstance } from "fastify"; import { WEB_ORIGIN } from "./config.js"; import { authRoutes } from "./features/auth/routes.js"; +import { blindspotCuratorRoutes } from "./features/blindspot/routes.js"; import { curatorRoutes } from "./features/curator/routes.js"; import { feedRoutes } from "./features/feed/routes.js"; import { storyRoutes } from "./features/story/routes.js"; import { ratingCuratorRoutes, ratingPublicRoutes } from "./features/rating/routes.js"; import { savedRoutes } from "./features/saved/routes.js"; +import { sourceRoutes } from "./features/source/routes.js"; import { topicRoutes } from "./features/topic/routes.js"; import { authPlugin } from "./plugins/auth.js"; import { cachePlugin } from "./plugins/cache.js"; @@ -64,6 +66,23 @@ export function buildApp(): FastifyInstance { return payload; }); + // CORS preflight: a browser sends an OPTIONS request before any cross-origin request + // carrying a non-simple header (every JSON POST/PUT/DELETE here sets Content-Type: + // application/json, which alone makes it non-simple) or a custom header (Authorization, + // X-Curator-Key). Without an explicit 2xx answer here the browser blocks the real request + // before it's ever sent — this app hand-rolls its CORS headers elsewhere (no @fastify/cors + // dependency), so the preflight needs the same treatment. Unauthenticated by design: the + // preflight carries no credentials for a hook to check. + app.options("*", async (_request, reply) => { + reply + .header("Access-Control-Allow-Origin", WEB_ORIGIN) + .header("Access-Control-Allow-Credentials", "true") + .header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, PATCH, OPTIONS") + .header("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Curator-Key") + .code(204) + .send(); + }); + app.get("/health", async () => ({ status: "ok" })); // /auth/* — neither plugin; CSRF checks and rate limits only (rate limits: inside authRoutes). @@ -79,6 +98,7 @@ export function buildApp(): FastifyInstance { await storyRoutes(publicScope); await topicRoutes(publicScope); await ratingPublicRoutes(publicScope); + await sourceRoutes(publicScope); }, { prefix: "/api/public" }); // /api/* — auth plugin, no cache plugin. Same direct-call reasoning as above. @@ -95,6 +115,7 @@ export function buildApp(): FastifyInstance { await curatorPlugin(curatorScope); await ratingCuratorRoutes(curatorScope); await curatorRoutes(curatorScope); + await blindspotCuratorRoutes(curatorScope); }, { prefix: "/api/curator" }); return app; diff --git a/apps/api/src/features/blindspot/routes.ts b/apps/api/src/features/blindspot/routes.ts new file mode 100644 index 0000000..b447ca5 --- /dev/null +++ b/apps/api/src/features/blindspot/routes.ts @@ -0,0 +1,25 @@ +import type { FastifyInstance } from "fastify"; + +import { getPool } from "../../db.js"; +import { InvalidCursorError } from "../../pagination.js"; +import { getBlindspotFeed } from "./service.js"; + +// Curator-only (PRODUCT §4.9): no calibration round has run yet, so no blindspot flag may be +// shown publicly. Registered under /api/curator/* only — moving it to /api/public/* is the +// trigger-fire moment §4.9 describes, not a code change. +export async function blindspotCuratorRoutes(app: FastifyInstance): Promise { + app.get("/blindspot-feed", async (request, reply) => { + const cursor = (request.query as { cursor?: unknown }).cursor; + const cursorRaw = typeof cursor === "string" ? cursor : undefined; + + try { + const result = await getBlindspotFeed(getPool(), cursorRaw); + return reply.send(result); + } catch (err) { + if (err instanceof InvalidCursorError) { + return reply.code(400).send({ error: "invalid_cursor" }); + } + throw err; + } + }); +} diff --git a/apps/api/src/features/blindspot/service.ts b/apps/api/src/features/blindspot/service.ts index cc9816f..68387df 100644 --- a/apps/api/src/features/blindspot/service.ts +++ b/apps/api/src/features/blindspot/service.ts @@ -22,9 +22,10 @@ export interface BlindspotResponse { nextCursor: string | null; } -// PRODUCT §4.9: not registered as a public route — no calibration round has run yet, so no -// blindspot flag may be shown publicly. This is the mechanism, built dormant, same pattern as -// every other "designed now, built when its trigger fires" item in the complexity budget. +// PRODUCT §4.9: registered curator-only (routes.ts) — no calibration round has run yet, so +// no blindspot flag may be shown publicly. Promoting this to /api/public/* is the trigger-fire +// moment §4.9 describes, same pattern as every other "designed now, built when its trigger +// fires" item in the complexity budget. export async function getBlindspotFeed(pool: Pool, cursorRaw: string | undefined): Promise { const cursor = cursorRaw === undefined ? null : decodeCursor(cursorRaw, FEED_NAME); const asOf = cursor === null ? new Date() : new Date(cursor.as_of); diff --git a/apps/api/src/features/source/repo.ts b/apps/api/src/features/source/repo.ts new file mode 100644 index 0000000..532bc4b --- /dev/null +++ b/apps/api/src/features/source/repo.ts @@ -0,0 +1,18 @@ +import type { Pool } from "pg"; + +export interface SourceRow { + id: string; + name: string; + domain: string; + status: "active" | "deprecated"; + deprecated_at: Date | null; + logo_url: string | null; +} + +export async function fetchSource(pool: Pool, sourceId: string): Promise { + const result = await pool.query( + "SELECT id, name, domain, status, deprecated_at, logo_url FROM sources WHERE id = $1", + [sourceId] + ); + return result.rows[0] ?? null; +} diff --git a/apps/api/src/features/source/routes.ts b/apps/api/src/features/source/routes.ts new file mode 100644 index 0000000..e298abd --- /dev/null +++ b/apps/api/src/features/source/routes.ts @@ -0,0 +1,32 @@ +import type { FastifyInstance } from "fastify"; + +import { getPool } from "../../db.js"; +import { getCachedPage, setCachedPage } from "../../page-cache.js"; +import { getSource, SourceNotFoundError } from "./service.js"; + +const CACHE_TTL_SECONDS = 3600; // §10 Redis cache plan: "Source metadata, TTL 1h" + +export async function sourceRoutes(app: FastifyInstance): Promise { + app.get("/sources/:id", async (request, reply) => { + const { id } = request.params as { id: string }; + const cacheKey = `page:source:${id}`; + + const cached = await getCachedPage(cacheKey); + if (cached !== null) { + return reply.send(JSON.parse(cached)); + } + + let result; + try { + result = await getSource(getPool(), id); + } catch (err) { + if (err instanceof SourceNotFoundError) { + return reply.code(404).send({ error: "not_found" }); + } + throw err; + } + + await setCachedPage(cacheKey, JSON.stringify(result), CACHE_TTL_SECONDS); + return reply.send(result); + }); +} diff --git a/apps/api/src/features/source/service.ts b/apps/api/src/features/source/service.ts new file mode 100644 index 0000000..5f40edc --- /dev/null +++ b/apps/api/src/features/source/service.ts @@ -0,0 +1,30 @@ +import type { Pool } from "pg"; + +import { fetchSource } from "./repo.js"; + +export class SourceNotFoundError extends Error {} + +export interface SourceResponse { + id: string; + name: string; + domain: string; + status: "active" | "deprecated"; + deprecatedAt: string | null; + logoUrl: string | null; +} + +// Source metadata for the web app's saved-sources screen and anywhere else a bare source id +// needs a display name (ARCHITECTURE §10 Redis plan already names "Source metadata, TTL 1h" — +// this fills the endpoint that plan assumed). +export async function getSource(pool: Pool, sourceId: string): Promise { + const source = await fetchSource(pool, sourceId); + if (source === null) throw new SourceNotFoundError(); + return { + id: source.id, + name: source.name, + domain: source.domain, + status: source.status, + deprecatedAt: source.deprecated_at?.toISOString() ?? null, + logoUrl: source.logo_url, + }; +} diff --git a/apps/api/test/blindspot.test.ts b/apps/api/test/blindspot.test.ts index fb4d60d..05dca58 100644 --- a/apps/api/test/blindspot.test.ts +++ b/apps/api/test/blindspot.test.ts @@ -1,10 +1,12 @@ import { describe, expect, it } from "vitest"; +import { buildApp } from "../src/app.js"; import { getPool } from "../src/db.js"; import { getBlindspotFeed } from "../src/features/blindspot/service.js"; import { addCoverage, createTestSource, createTestStory, rateSource } from "./feed-helpers.js"; const BASELINE = { pLeft: 0.3, pCenter: 0.35, pRight: 0.35 }; +const CURATOR_KEY = "dev-insecure-curator-key"; async function insertBaseline(asOf: Date = new Date()): Promise { await getPool().query( @@ -93,3 +95,31 @@ describe("getBlindspotFeed (PRODUCT §4)", () => { expect(result.items.find((i) => i.id === storyId)).toBeUndefined(); }); }); + +describe("GET /api/curator/blindspot-feed", () => { + it("rejects requests without the curator key", async () => { + const app = buildApp(); + const res = await app.inject({ method: "GET", url: "/api/curator/blindspot-feed" }); + expect(res.statusCode).toBe(401); + await app.close(); + }); + + it("serves the same feed the service computes, behind the curator key", async () => { + await insertBaseline(); + const sources = await Promise.all(Array.from({ length: 6 }, () => createTestSource("blindspot-route"))); + await Promise.all(sources.map((s) => rateSource(s, { biasRating: "left", confidence: "high" }))); + const { storyId } = await createTestStory(sources[0]); + await Promise.all(sources.slice(1).map((s) => addCoverage(storyId, s))); + + const app = buildApp(); + const res = await app.inject({ + method: "GET", + url: "/api/curator/blindspot-feed", + headers: { "x-curator-key": CURATOR_KEY }, + }); + expect(res.statusCode).toBe(200); + const item = res.json().items.find((i: { id: string }) => i.id === storyId); + expect(item?.side).toBe("R"); + await app.close(); + }); +}); diff --git a/apps/api/test/source.test.ts b/apps/api/test/source.test.ts new file mode 100644 index 0000000..2fda926 --- /dev/null +++ b/apps/api/test/source.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; + +import { buildApp } from "../src/app.js"; +import { createTestSource } from "./feed-helpers.js"; +import { delTestCache } from "./redis-helpers.js"; + +describe("GET /api/public/sources/:id", () => { + it("returns the source's display metadata", async () => { + const sourceId = await createTestSource("source-meta"); + await delTestCache(`page:source:${sourceId}`); + + const app = buildApp(); + const res = await app.inject({ method: "GET", url: `/api/public/sources/${sourceId}` }); + expect(res.statusCode).toBe(200); + + const body = res.json(); + expect(body.id).toBe(sourceId); + expect(body.domain).toContain("source-meta"); + expect(body.status).toBe("active"); + expect(body.deprecatedAt).toBeNull(); + await app.close(); + }); + + it("returns 404 for a source that doesn't exist", async () => { + const app = buildApp(); + const res = await app.inject({ + method: "GET", + url: "/api/public/sources/00000000-0000-0000-0000-000000000000", + }); + expect(res.statusCode).toBe(404); + expect(res.json()).toEqual({ error: "not_found" }); + await app.close(); + }); +}); diff --git a/apps/web/.gitignore b/apps/web/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/apps/web/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/apps/web/.oxlintrc.json b/apps/web/.oxlintrc.json new file mode 100644 index 0000000..6fa991d --- /dev/null +++ b/apps/web/.oxlintrc.json @@ -0,0 +1,8 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "plugins": ["react", "typescript", "oxc"], + "rules": { + "react/rules-of-hooks": "error", + "react/only-export-components": ["warn", { "allowConstantExport": true }] + } +} diff --git a/apps/web/README.md b/apps/web/README.md new file mode 100644 index 0000000..d6af7e3 --- /dev/null +++ b/apps/web/README.md @@ -0,0 +1,32 @@ +# React + TypeScript + Vite + +This template provides a minimal setup to get React working in Vite with HMR and some Oxlint rules. + +Currently, two official plugins are available: + +- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs) +- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) + +## React Compiler + +The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation). + +## Expanding the Oxlint configuration + +If you are developing a production application, we recommend enabling type-aware lint rules by installing `oxlint-tsgolint` and editing `.oxlintrc.json`: + +```json +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "plugins": ["react", "typescript", "oxc"], + "options": { + "typeAware": true + }, + "rules": { + "react/rules-of-hooks": "error", + "react/only-export-components": ["warn", { "allowConstantExport": true }] + } +} +``` + +See the [Oxlint rules documentation](https://oxc.rs/docs/guide/usage/linter/rules) for the full list of rules and categories. diff --git a/apps/web/index.html b/apps/web/index.html new file mode 100644 index 0000000..ad74e19 --- /dev/null +++ b/apps/web/index.html @@ -0,0 +1,13 @@ + + + + + + + Newstack + + +
+ + + diff --git a/apps/web/package-lock.json b/apps/web/package-lock.json new file mode 100644 index 0000000..3b514e6 --- /dev/null +++ b/apps/web/package-lock.json @@ -0,0 +1,3147 @@ +{ + "name": "web", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "web", + "version": "0.0.0", + "dependencies": { + "@tanstack/react-query": "^5.104.1", + "react": "^19.2.8", + "react-dom": "^19.2.8", + "react-router-dom": "^7.18.4", + "zustand": "^5.0.15" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.3.3", + "@testing-library/jest-dom": "^7.0.1", + "@testing-library/react": "^16.3.3", + "@types/node": "^24.19.2", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.7", + "@vitejs/plugin-react": "^6.1.1", + "jsdom": "^29.1.1", + "oxlint": "^1.81.0", + "tailwindcss": "^4.3.3", + "typescript": "~6.0.2", + "vite": "^8.3.0", + "vitest": "^5.0.3" + } + }, + "node_modules/@adobe/css-tools": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz", + "integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", + "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@csstools/css-calc": "^3.2.0", + "@csstools/css-color-parser": "^4.1.0", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", + "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/generational-cache": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", + "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.10", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.10.tgz", + "integrity": "sha512-NpugTzbKgGrNe45Ui32cN8vCFFMxVtP/9rtfwv0nP5FRDAjvYBxJcLVpYA9mr1jxP6cnIT7x9LUzuDTbWYYNRA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bramus/specificity": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.3.tgz", + "integrity": "sha512-0GEz8qJWokkUVYQYRukmkSHIFNoBH8QTreeHrAFbvz1XFYc3Y+puhMs8PQ5x8GNugiC2l5jvDBXKLbBIByeSBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.2.1" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.2.tgz", + "integrity": "sha512-grhRy3OKmniaAEKXMjua5z/EODX0MSqBGjunw8+j/3HQjOnahs2AGhvEOIYVUWcU6ScApbhLhVrQTX8XqrMrow==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.4.3.tgz", + "integrity": "sha512-iex20d8CHVkyvg6B7UKV7uHnI2Bqo9g+EFfT9E0y+GvTvhZ/DwONJ+9aKb1dlqm0ZiGsL5RXjp0fCoJYnkeDjA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.2", + "@csstools/css-tokenizer": "^4.0.2" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.2.6", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.6.tgz", + "integrity": "sha512-iiPQ3iRWwnJkeEn6RIu6SJPr7hYrLz6XZ9s/QZl+2/LI5KQVjpl2fdmDSZKuD4xP6GMmMPgHFFXg6k1Wkz0Trg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.1.2", + "@csstools/css-calc": "^3.4.3" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.2", + "@csstools/css-tokenizer": "^4.0.2" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.2.tgz", + "integrity": "sha512-40cSKyMvK+tq4qz6Awrlye2WGuOKt3FwPgtGg6KTfbHOWNw+Rk1rzbAtZnZ6IBhsY491HLRnDXwoyBAijmmILA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.2" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.15.tgz", + "integrity": "sha512-J0u7HkVl2nzSlhsiTOp4AmwcUQ3D+mGEEKfBy/7To5/y7F2OHwyLrXfrhR0SMgr4p5Lo+eaMVSeai24zUcBIxA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.2.tgz", + "integrity": "sha512-OoKoR0f76dCY666JlcbhmVTs2drYj1GUXZTYTcbUgJjh9Nv41aFfZ21bPQTERm5+L5cBDo466NltB2lplS5GBw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@exodus/bytes": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.16.0.tgz", + "integrity": "sha512-IcpW84uEn3N7ETtNZMlxKhfl6Pec8rUNGOTBtWbK1FKhJxIFAptZyVrvVRVBimAJxJCgc3PxepxkdWWG4DVzfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.153.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.153.0.tgz", + "integrity": "sha512-jSNrRIyvPVLL5E1z/tS5zLAprosGz/FuI1PWwEOz59j3E4TTTzSpqKb/RDGqJhhW0QHYISGXxq582wGx6T11Tw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, + "node_modules/@oxlint/binding-android-arm-eabi": { + "version": "1.87.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.87.0.tgz", + "integrity": "sha512-22tVIHlB9898OYsBKmYB6He1MCSqdTmK1BVWuSNUTVrpS0KWNege+U9uEYqGJ+KatQag0r/oU44i3OOs46z7HQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-android-arm64": { + "version": "1.87.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.87.0.tgz", + "integrity": "sha512-JfZQ3MWf74n8ov0DojZpkOduIubYJvZL5tbTMMhDutsal6u1xH1QuNpN+lK+EQwF8AeAfgwTR2fgh5U2br27XA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-arm64": { + "version": "1.87.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.87.0.tgz", + "integrity": "sha512-rkx6Xvvw93z2GImVOyvbZwAZcoY0Z6MJW177XNsSB+i/CBsH7ANyelTp01VZyIkM3iSXtqKVb4+ptBgPHap5pQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-x64": { + "version": "1.87.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.87.0.tgz", + "integrity": "sha512-cw5nYw7L/pQ2SgGBgOcg/Qo89llQre3vIc/kuBLhSICzu1r5s8L76H3ug6AIFsD98WpROj8/z5JVzkf4daxHaQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-freebsd-x64": { + "version": "1.87.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.87.0.tgz", + "integrity": "sha512-x/SuznE6loRZK9gz9AS4ZmzNzSu+5KdeoCS+sVHhnrE8HM6AD8UyTjwldQTTqlXGvkwHASgB0NiTNOc3o3PdAg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-gnueabihf": { + "version": "1.87.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.87.0.tgz", + "integrity": "sha512-+JEDmCAXiiFmKhAjZhgAOwaHWY6WENOQ5QA+lFuY59tgsUCDOTRi46B+lENK6CWvtxsXpwkZXNOgpjAQcpIpnA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-musleabihf": { + "version": "1.87.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.87.0.tgz", + "integrity": "sha512-/uC9XqXLAC9AHFLNycwmSQEvKqGrOCgU+uXALI7gNW0DG81TJ/3T2eHnKdRvcb5R1gFEfjxDwI4e4zIa29rICQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-gnu": { + "version": "1.87.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.87.0.tgz", + "integrity": "sha512-FTRYPsBy/M728vnUv7l+KAwSAa3Qi4BQbhVvy7NYuMTZ0sUa/OWsbMx79HVFw6u/5KWcaYayDsoOiv7xbevzDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-musl": { + "version": "1.87.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.87.0.tgz", + "integrity": "sha512-6sC+N7IY0h1egd8SrYUGNN7CUyKJZ4bIZakKa7V7hfinf4jfi8Kq4+klJvFp5DqXcwMn44I5MeBwmJluO1odFg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-ppc64-gnu": { + "version": "1.87.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.87.0.tgz", + "integrity": "sha512-d5Mnn+ICbhoJPi37Ccjy+86T5qfH2+mSTrTMBXNPfB+wqSWOayBBLLIrCXdUy1YssluCVdGkl+DO9w5r5ODmUA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-gnu": { + "version": "1.87.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.87.0.tgz", + "integrity": "sha512-sf5YWuUo1Dx9loqWCgF8P278MycfcD4JHjDC/LV5j11L3Lqa4PFiwCjD6B8kP4AjsgZZkLn3xbpJEtZBaccs4Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-musl": { + "version": "1.87.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.87.0.tgz", + "integrity": "sha512-+aCraYbmK3uU3P0rFojjeaK9pGY6JvBDXh/0CM+gPyWKmNmXmnFe0qj4qj02U4ibFpbd9Pa9CmDvIdjJUd29rA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-s390x-gnu": { + "version": "1.87.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.87.0.tgz", + "integrity": "sha512-7rzbZQIljo6VLWVPqUyLa2P/WV3VFsJB4M52R4NCUJn1A50yjtUsHxiMHFL9opEKGFIiTbZ3VQzmtcJOPUq4NQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-gnu": { + "version": "1.87.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.87.0.tgz", + "integrity": "sha512-Dry46Fa+AXE/xt9J/YriujU1pU9mzfNWHGQwtOYibAFH8HLrKWCi93AhIGnuUMiwn2JbuBkRyz51f50Ly4r4ow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-musl": { + "version": "1.87.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.87.0.tgz", + "integrity": "sha512-f/HMZez74gJvnRpGz+b5sdGyWrEgRLmFTXg9sNoqjPWogGxYHUhXKTKyMiYsu9rUGR2ciYqL3fk+Ua4JhMJbRA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-openharmony-arm64": { + "version": "1.87.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.87.0.tgz", + "integrity": "sha512-lEC6ZfPZm/07JlB7FsOWX9o1RiHlXiBW594LSUGuXmekC8LZmvUPP9XzE2WZcNxJi5cHA/Fw56o02c3JxgMA5g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-arm64-msvc": { + "version": "1.87.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.87.0.tgz", + "integrity": "sha512-afBWKOD36dUiA9AenaiWpCg8xfyUVPYznyAAVXu413brK4RA5UI/Xoc4yfeGIT8G1a44MV+JR7hioryerBDzGQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-ia32-msvc": { + "version": "1.87.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.87.0.tgz", + "integrity": "sha512-daptNsEOuru4/nJTxV4DBHNJ5GepzmaBVQf+jjgKQGi9NRlZKik+RFq4hUL7ewsF1pwjKM+SkYaMbF82UwFGnw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-x64-msvc": { + "version": "1.87.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.87.0.tgz", + "integrity": "sha512-k+m/EShyXE29J/r1fjk0HlAzhN5JhIvj044oSJkXTEkCLrWZQgXb/3r4q5LTw/eaKW/4GuMXG2xmiJs3s5rPNw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.13.tgz", + "integrity": "sha512-Vu/NICrgdRvUOeaJZIyB2FP63s6fO/4ud5l8dXmxDx9yDyn+mN0y+gsvsVviSli+T361Aqo0h46EuMoobrwawA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.13.tgz", + "integrity": "sha512-6LEoIrGy5vvCd2boNnv2soPSUZj/PRvhp8R/t9KlX+mIZxThIE/ZqxrGXxmuNX0FuvQFvLdZr7S4HrYpYBVhEQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.13.tgz", + "integrity": "sha512-CaJSRzlQ6Q+zkDMVA9OkVxBjVoPRCDEE3v0FSJ9kIQBgRjFApA3Si4ijhbNVUji7ZJt+PfO0e46qy4nPInHUPA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.13.tgz", + "integrity": "sha512-e4r340I5hxSzJkD3YJBxgzq/rJ3wwxwdLLb2FPt1Zpyem20nrQPT11DDEEkazwxaHumH8wpglsp/Ozgj/wT6mw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.13.tgz", + "integrity": "sha512-hoBWDZ4/K/mJKVMbsgUd1jfpA2DiOrJ54mpegObU9TBHBBrtX4FLIXJJQRDPZ/2Dn91GvPob+Z2hwKJzf4E5SQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.13.tgz", + "integrity": "sha512-oF9fZu3vTWnTiVAkDFxU9PJLt1nh20K9RRpuHsR7ZnE2L7XookBbDBVLojCVSLGbvtSlxeUNnjAj6DbNsKyzbg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.13.tgz", + "integrity": "sha512-DTpb/+jMiqWMPgphn4ksW1zW+XLJ6dJJ9FMvnIk9I291LhCctMPhMYJrP5t9p6C9qa3KXadDdUEZHBaPh3kNxQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.13.tgz", + "integrity": "sha512-xU+Kvv9N2A3SY35zFh1FWWNjxbadE/BKeqnkXe7vScU4GZsw757BlVmDLITZEq8DPpk6m7uoDYkK7P+x/uU5Ow==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.13.tgz", + "integrity": "sha512-XMk/hnhh25oQ756Lclb1Q11h4ll0xC0Esa5w4Vzp+mAE2S3AGJgeM0SonrIgY8fHhjarb6hcoTuwoFG0VVP2ig==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.13.tgz", + "integrity": "sha512-U1BoTHYmlywE2n3HUWjzZ+8EQsKr+OwpxYuDWaubx42tUL8QSL7nOqRz/NVHanibbFP3M1Rxuprz/wNcbMKjqw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.13.tgz", + "integrity": "sha512-J4cm80Pc6wEwV5AZ42o62sIQgDk+Y7R2s646jIwuvO+4V46QB58N4vVuMPWCBaNgursXLpmUWJV//WQAkPvHBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.13.tgz", + "integrity": "sha512-W0euZS40U4V6JkmEnVCIX9mcsL6cbXQ4X8c2w+MMOIjFg68TXUW+u3RT+2e1t1pv9mg/EP3pDtK3POXdQ7Rxyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.13.tgz", + "integrity": "sha512-do/AF/4n9MRshia3a5aXoy8xM2c6mgfT6D7H/dY6my5z+hfCIR4ab7mk4IRj++nsEQpKy39kd0DQnDq+r692tw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.13.tgz", + "integrity": "sha512-6FtUf2cJZobutJdxnGYX8z0vtbgqU0b2v8vfBhkXb9wp2MppMFtkunE+IaOntyi01rkIQoPW2GHABtfzsC0BRQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.13.tgz", + "integrity": "sha512-UKhFaZTeTcbKrb3iU82m17jVcPEAoqDkCN5MBqgPyZSwp/cQmib21crTS0DdH7T1B7wZRWEI89YQAOJMet5yhg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz", + "integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.24.1", + "jiti": "^2.7.0", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.3" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.3.tgz", + "integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-x64": "4.3.3", + "@tailwindcss/oxide-freebsd-x64": "4.3.3", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-x64-musl": "4.3.3", + "@tailwindcss/oxide-wasm32-wasi": "4.3.3", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz", + "integrity": "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz", + "integrity": "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz", + "integrity": "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz", + "integrity": "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.3.tgz", + "integrity": "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.3.tgz", + "integrity": "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.3.tgz", + "integrity": "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz", + "integrity": "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz", + "integrity": "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.3.tgz", + "integrity": "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.11.1", + "@emnapi/runtime": "^1.11.1", + "@emnapi/wasi-threads": "^1.2.2", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz", + "integrity": "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.3.tgz", + "integrity": "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.3.tgz", + "integrity": "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.3.3", + "@tailwindcss/oxide": "4.3.3", + "tailwindcss": "4.3.3" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@tanstack/query-core": { + "version": "5.104.1", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.104.1.tgz", + "integrity": "sha512-TiRghcrGkUTE+M4JBxvpPSpBhxvIzQairw+cksrHYGr+2uwC77yW1SY9nYxnYRYOppvucf8rEg6b5byoz4rTpw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.104.1", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.104.1.tgz", + "integrity": "sha512-Zz70EgjahNI7aCz8BZxM3vUGU44ycxEAzMuCUjLeA7SVhhOkg3ZgPjPJPvHjs6Rmky/dtmnu1WzjGHNHmhCoZQ==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.104.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@testing-library/dom": { + "version": "10.4.2", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.2.tgz", + "integrity": "sha512-yzr2S9HyAIdhz2/6qHgbs665Q7PKVcDF05vsOlHPxG1mo36gKVesdYVeDLnXgfjJ03CrKRk08knc6+E/9m8v2Q==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-7.0.1.tgz", + "integrity": "sha512-oMDTC3oA+6CXSO2JZnvOI7CA6oVub6kij5ggk9ohwye5slmkwxYDXcPOVxgMw/RQlticjtO0C1RZkR97HgrWMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=22", + "npm": ">=6", + "yarn": ">=1" + }, + "peerDependencies": { + "@testing-library/dom": ">=10 <11", + "vitest": ">= 0.32" + }, + "peerDependenciesMeta": { + "vitest": { + "optional": true + } + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.3.3", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.3.tgz", + "integrity": "sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.19.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.19.2.tgz", + "integrity": "sha512-93d49wCWJkCyN87MxLpNN6KXPCwFw7bVUg79mGUV1OS40JGWwVCr8n61DXCSqz7CV6/VfPkb7xec7uP2M+QHWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": ">=7.24.0 <7.24.7" + } + }, + "node_modules/@types/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz", + "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.3.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.2.tgz", + "integrity": "sha512-fhJNGUdscQa4HkI0xFxwonWyhnVReCPDbXI7FH7jmzpnVCrFQI7PRrhWecO263610vOrs9tr2Y4xcf8uAdFxbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "oxc-transform-react": "^0.152.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "oxc-transform-react": { + "optional": true + } + } + }, + "node_modules/@vitest/mocker": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.3.tgz", + "integrity": "sha512-T8sWAIbkSyAjkwTcaEc3Iu0o9A27X1/kdXrizhZkGuSKScRQtRzclfAMpOTcGdXCsqxeWlpGy3XjqaW8CpLORg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.31", + "@vitest/spy": "5.0.3", + "estree-walker": "^3.0.3", + "magic-string": "^1.2.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/mocker/node_modules/magic-string": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.3.tgz", + "integrity": "sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.6.0" + } + }, + "node_modules/@vitest/spy": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.3.tgz", + "integrity": "sha512-XhFysQTB8AZ+P4gMi+Lpo99vg2AZi0qKpaB9yXQl37+CaMEAPO3iH/wGVnSyL5MPERiLezpqTVtrR6UZH5GCXg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/bidi-js": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.1.0.tgz", + "integrity": "sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, + "node_modules/chai": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz", + "integrity": "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/enhanced-resolve": { + "version": "5.26.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.26.0.tgz", + "integrity": "sha512-9vhedylFonb2YGogzUKX6+Ja72gOJbN1QHAqdrvqLwhdl/QWbKopzoUC9EbQNVsAns/bx/4uyqalrQAoy1IByw==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/entities": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz", + "integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "dev": true, + "license": "MIT" + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/jsdom": { + "version": "29.1.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz", + "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.1.11", + "@asamuzakjp/dom-selector": "^7.1.1", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.3", + "@exodus/bytes": "^1.15.0", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.3.5", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.1", + "undici": "^7.25.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lru-cache": { + "version": "11.5.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/nanoid": { + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/obug": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz", + "integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/oxlint": { + "version": "1.87.0", + "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.87.0.tgz", + "integrity": "sha512-drxVlaW7Oqq7wSy1CN25znRu00aRq8n57Q2wniJRxCmtYVz07ufCm7EVDPQFZURrTgQIjzKrVEHAD/HcvJXObg==", + "dev": true, + "license": "MIT", + "bin": { + "oxlint": "bin/oxlint" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/oxc-project" + }, + "optionalDependencies": { + "@oxlint/binding-android-arm-eabi": "1.87.0", + "@oxlint/binding-android-arm64": "1.87.0", + "@oxlint/binding-darwin-arm64": "1.87.0", + "@oxlint/binding-darwin-x64": "1.87.0", + "@oxlint/binding-freebsd-x64": "1.87.0", + "@oxlint/binding-linux-arm-gnueabihf": "1.87.0", + "@oxlint/binding-linux-arm-musleabihf": "1.87.0", + "@oxlint/binding-linux-arm64-gnu": "1.87.0", + "@oxlint/binding-linux-arm64-musl": "1.87.0", + "@oxlint/binding-linux-ppc64-gnu": "1.87.0", + "@oxlint/binding-linux-riscv64-gnu": "1.87.0", + "@oxlint/binding-linux-riscv64-musl": "1.87.0", + "@oxlint/binding-linux-s390x-gnu": "1.87.0", + "@oxlint/binding-linux-x64-gnu": "1.87.0", + "@oxlint/binding-linux-x64-musl": "1.87.0", + "@oxlint/binding-openharmony-arm64": "1.87.0", + "@oxlint/binding-win32-arm64-msvc": "1.87.0", + "@oxlint/binding-win32-ia32-msvc": "1.87.0", + "@oxlint/binding-win32-x64-msvc": "1.87.0" + }, + "peerDependencies": { + "oxlint-tsgolint": ">=7.0.2003", + "vite-plus": "*" + }, + "peerDependenciesMeta": { + "oxlint-tsgolint": { + "optional": true + }, + "vite-plus": { + "optional": true + } + } + }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.29", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.29.tgz", + "integrity": "sha512-49cGhUbXj8Qenv0iTMxA1cFBzxXoctpC9Ujd77t1WcbJIr6nF/eI7g/8MgxrYldFRuAXvja7xQRwavoW7kgrxQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.19", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz", + "integrity": "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.28.0" + }, + "peerDependencies": { + "react": "^19.3.0" + } + }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/react-router": { + "version": "7.18.4", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.4.tgz", + "integrity": "sha512-PUPQcMhMGRAslLcvtlPz/kmzBEWPhLdgLFrL7pLNepBL6dX0lWj4WD2cUYVgYCuT3jxvghYFg81cDTj44DhetQ==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.18.4", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.4.tgz", + "integrity": "sha512-yrfmJHIpDG7taCpqKjT1G5B6q3O2K+RN8/fgNf0lTjCwiPbQ0ei6vXX9ZjQR+7ld8Tr7Z5xmyMnZ8YJrphWQUw==", + "license": "MIT", + "dependencies": { + "react-router": "7.18.4" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rolldown": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.13.tgz", + "integrity": "sha512-/5apOkwWxtACcQtZ5WlrAsKS4JwXVThQPJZ3RqIruRY9UG9M8e0VlXN068xum/JRKA4aIUoJ13ghe/kGCvidiQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.153.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.13", + "@rolldown/binding-android-arm64": "1.2.13", + "@rolldown/binding-darwin-arm64": "1.2.13", + "@rolldown/binding-darwin-x64": "1.2.13", + "@rolldown/binding-freebsd-x64": "1.2.13", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.13", + "@rolldown/binding-linux-arm64-gnu": "1.2.13", + "@rolldown/binding-linux-arm64-musl": "1.2.13", + "@rolldown/binding-linux-ppc64-gnu": "1.2.13", + "@rolldown/binding-linux-s390x-gnu": "1.2.13", + "@rolldown/binding-linux-x64-gnu": "1.2.13", + "@rolldown/binding-linux-x64-musl": "1.2.13", + "@rolldown/binding-openharmony-arm64": "1.2.13", + "@rolldown/binding-win32-arm64-msvc": "1.2.13", + "@rolldown/binding-win32-x64-msvc": "1.2.13" + } + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/scheduler": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz", + "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==", + "license": "MIT" + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/source-map-js": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/std-env": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.3.0.tgz", + "integrity": "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tailwindcss": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", + "integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tinybench": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.2.2.tgz", + "integrity": "sha512-nIZ2N2dFpjBsg2hgitKu7mA+FvsIJuGJOwysRngmVRRmWgR/UyPjwJj8c/fT/dhm8EkHnF5EOogRf+0Z2qrimA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/tinyexec": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz", + "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tldts": { + "version": "7.4.18", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.18.tgz", + "integrity": "sha512-somxA8xKAUzlAxl0TX/jMSYIbRNFIVGnI96pVGJPeMc5BsHmfVXn6mOJT6U++ELnl04eGfzpfcEuZe8WiASkXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.18" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.18", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.18.tgz", + "integrity": "sha512-KI2PKvCmvQI/xD6ypPqkU0i7oxNXR/O58RLRaHR4svsgVUQ2JE/XC8BtMbG6PSehD27I8sZJRcC1ASnCy6lp3g==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici": { + "version": "7.30.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.30.0.tgz", + "integrity": "sha512-dkrQXeHSaoamnItlYbmzG0wFYrM0ZwDxCIg0A7aKjTyyhh9svRzCNFEzV+Vm05/yehjCzjDZ31KXfGEjYSztDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/undici-types": { + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "8.3.4", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.4.tgz", + "integrity": "sha512-IftLE8phJhdNRIGNEV4JCCvfNScoZQVfcXaN8TQropWGJtOHcu+iAB9pHcuGvmEb/kddw0LBe/jS8ztdC6xdiQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.7", + "postcss": "^8.5.29", + "rolldown": "~1.2.12", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.7.1", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.3.tgz", + "integrity": "sha512-xMw97S3rjdtj5dkVat7jCsqWBpvchs3RlpQctUqwJD0KkERk40vz2fJ77lDwW/Vzh/pk18eItYAzkodhSes3jQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/mocker": "5.0.3", + "chai": "^6.2.2", + "es-module-lexer": "^2.3.2", + "expect-type": "^1.4.0", + "magic-string": "^1.2.3", + "obug": "^2.1.4", + "picomatch": "^4.0.7", + "std-env": "^4.2.0", + "tinybench": "^6.1.4", + "tinyexec": "^1.3.0", + "tinyglobby": "^0.2.17", + "why-is-node-running": "3.2.1" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^22.12.0 || ^24.0.0 || >=26.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "5.0.3", + "@vitest/browser-preview": "5.0.3", + "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", + "@vitest/coverage-istanbul": "5.0.3", + "@vitest/coverage-v8": "5.0.3", + "@vitest/ui": "5.0.3", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.4.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/vitest/node_modules/magic-string": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.3.tgz", + "integrity": "sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.6.0" + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/why-is-node-running": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-3.2.1.tgz", + "integrity": "sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ==", + "dev": true, + "license": "MIT", + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=20.11" + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, + "node_modules/zustand": { + "version": "5.0.15", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.15.tgz", + "integrity": "sha512-MpSEjRiBkA9crSYeOUH32rJC7SVqAbm0Fqcqge/bUi2PPoLcBWKOsG+C8mevmpr8TwXHBVkChbbJiyvkE+i/3A==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } + } + } +} diff --git a/apps/web/package.json b/apps/web/package.json new file mode 100644 index 0000000..32a118a --- /dev/null +++ b/apps/web/package.json @@ -0,0 +1,34 @@ +{ + "name": "web", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "oxlint", + "preview": "vite preview" + }, + "dependencies": { + "@tanstack/react-query": "^5.104.1", + "react": "^19.2.8", + "react-dom": "^19.2.8", + "react-router-dom": "^7.18.4", + "zustand": "^5.0.15" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.3.3", + "@testing-library/jest-dom": "^7.0.1", + "@testing-library/react": "^16.3.3", + "@types/node": "^24.19.2", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.7", + "@vitejs/plugin-react": "^6.1.1", + "jsdom": "^29.1.1", + "oxlint": "^1.81.0", + "tailwindcss": "^4.3.3", + "typescript": "~6.0.2", + "vite": "^8.3.0", + "vitest": "^5.0.3" + } +} diff --git a/apps/web/public/favicon.svg b/apps/web/public/favicon.svg new file mode 100644 index 0000000..6893eb1 --- /dev/null +++ b/apps/web/public/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/web/public/icons.svg b/apps/web/public/icons.svg new file mode 100644 index 0000000..e952219 --- /dev/null +++ b/apps/web/public/icons.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx new file mode 100644 index 0000000..27fe38c --- /dev/null +++ b/apps/web/src/App.tsx @@ -0,0 +1,13 @@ +import { QueryClientProvider } from '@tanstack/react-query' +import { RouterProvider } from 'react-router-dom' + +import { router } from './app/router' +import { queryClient } from './lib/queryClient' + +export function App() { + return ( + + + + ) +} diff --git a/apps/web/src/app/RootLayout.tsx b/apps/web/src/app/RootLayout.tsx new file mode 100644 index 0000000..d7779da --- /dev/null +++ b/apps/web/src/app/RootLayout.tsx @@ -0,0 +1,43 @@ +import { Link, Outlet } from 'react-router-dom' + +import { useAuthStore } from '../lib/authStore' + +export function RootLayout() { + const { accessToken, clearSession } = useAuthStore() + const isSignedIn = accessToken !== null + + return ( +
+
+ + Newstack + + +
+
+ +
+
+ ) +} diff --git a/apps/web/src/app/router.tsx b/apps/web/src/app/router.tsx new file mode 100644 index 0000000..3c197c8 --- /dev/null +++ b/apps/web/src/app/router.tsx @@ -0,0 +1,15 @@ +import { createBrowserRouter } from 'react-router-dom' + +import { authRoutes } from '../features/auth' +import { curatorRoutes } from '../features/curator' +import { feedRoutes } from '../features/feed' +import { savedRoutes } from '../features/saved' +import { topicRoutes } from '../features/topics' +import { RootLayout } from './RootLayout' + +export const router = createBrowserRouter([ + { + element: , + children: [...feedRoutes, ...authRoutes, ...topicRoutes, ...savedRoutes, ...curatorRoutes], + }, +]) diff --git a/apps/web/src/components/QueryStates.tsx b/apps/web/src/components/QueryStates.tsx new file mode 100644 index 0000000..c3e6bc8 --- /dev/null +++ b/apps/web/src/components/QueryStates.tsx @@ -0,0 +1,24 @@ +export function ErrorState({ message }: { message: string }) { + return

{message}

+} + +export function LoadingState() { + return

Loading…

+} + +export function EmptyState({ message }: { message: string }) { + return

{message}

+} + +export function LoadMoreButton({ onClick, loading }: { onClick: () => void; loading: boolean }) { + return ( + + ) +} diff --git a/apps/web/src/components/RequireAuth.tsx b/apps/web/src/components/RequireAuth.tsx new file mode 100644 index 0000000..d07898e --- /dev/null +++ b/apps/web/src/components/RequireAuth.tsx @@ -0,0 +1,13 @@ +import type { ReactNode } from 'react' +import { Navigate } from 'react-router-dom' + +import { useAuthStore } from '../lib/authStore' +import { LoadingState } from './QueryStates' + +export function RequireAuth({ children }: { children: ReactNode }) { + const { accessToken, authReady } = useAuthStore() + + if (!authReady) return + if (accessToken === null) return + return children +} diff --git a/apps/web/src/features/auth/api.ts b/apps/web/src/features/auth/api.ts new file mode 100644 index 0000000..0a36b30 --- /dev/null +++ b/apps/web/src/features/auth/api.ts @@ -0,0 +1,26 @@ +import { apiFetch } from '../../lib/apiClient' +import { decodeAccessTokenSubject } from '../../lib/jwt' +import { useAuthStore } from '../../lib/authStore' +import type { LoginResponse } from './types' + +export function signup(email: string, password: string): Promise { + return apiFetch('/auth/signup', { method: 'POST', body: { email, password } }) +} + +export function verifyEmail(token: string): Promise { + return apiFetch('/auth/verify', { method: 'POST', body: { token } }) +} + +export async function login(email: string, password: string): Promise { + const { accessToken } = await apiFetch('/auth/login', { method: 'POST', body: { email, password } }) + const sub = decodeAccessTokenSubject(accessToken) + useAuthStore.getState().setSession(accessToken, { id: sub ?? '' }) +} + +export function requestPasswordReset(email: string): Promise { + return apiFetch('/auth/reset/request', { method: 'POST', body: { email } }) +} + +export function confirmPasswordReset(token: string, newPassword: string): Promise { + return apiFetch('/auth/reset/confirm', { method: 'POST', body: { token, newPassword } }) +} diff --git a/apps/web/src/features/auth/hooks/useAuthMutations.ts b/apps/web/src/features/auth/hooks/useAuthMutations.ts new file mode 100644 index 0000000..7f7e803 --- /dev/null +++ b/apps/web/src/features/auth/hooks/useAuthMutations.ts @@ -0,0 +1,25 @@ +import { useMutation } from '@tanstack/react-query' + +import { confirmPasswordReset, login, requestPasswordReset, signup, verifyEmail } from '../api' + +export function useSignup() { + return useMutation({ mutationFn: ({ email, password }: { email: string; password: string }) => signup(email, password) }) +} + +export function useLogin() { + return useMutation({ mutationFn: ({ email, password }: { email: string; password: string }) => login(email, password) }) +} + +export function useVerifyEmail() { + return useMutation({ mutationFn: (token: string) => verifyEmail(token) }) +} + +export function useRequestPasswordReset() { + return useMutation({ mutationFn: (email: string) => requestPasswordReset(email) }) +} + +export function useConfirmPasswordReset() { + return useMutation({ + mutationFn: ({ token, newPassword }: { token: string; newPassword: string }) => confirmPasswordReset(token, newPassword), + }) +} diff --git a/apps/web/src/features/auth/index.ts b/apps/web/src/features/auth/index.ts new file mode 100644 index 0000000..7895a04 --- /dev/null +++ b/apps/web/src/features/auth/index.ts @@ -0,0 +1 @@ +export { authRoutes } from './routes' diff --git a/apps/web/src/features/auth/pages/LoginPage.tsx b/apps/web/src/features/auth/pages/LoginPage.tsx new file mode 100644 index 0000000..b4f4f43 --- /dev/null +++ b/apps/web/src/features/auth/pages/LoginPage.tsx @@ -0,0 +1,53 @@ +import { useState, type FormEvent } from 'react' +import { Link, useNavigate } from 'react-router-dom' + +import { useLogin } from '../hooks/useAuthMutations' + +export function LoginPage() { + const [email, setEmail] = useState('') + const [password, setPassword] = useState('') + const login = useLogin() + const navigate = useNavigate() + + function onSubmit(e: FormEvent) { + e.preventDefault() + login.mutate({ email, password }, { onSuccess: () => navigate('/') }) + } + + return ( +
+

Log in

+ setEmail(e.target.value)} + className="rounded border border-neutral-300 px-3 py-2 dark:border-neutral-700 dark:bg-neutral-900" + /> + setPassword(e.target.value)} + className="rounded border border-neutral-300 px-3 py-2 dark:border-neutral-700 dark:bg-neutral-900" + /> + {login.isError && ( + // §9: one generic message for both unknown email and wrong password. +

Invalid email or password.

+ )} + +
+ Sign up + Forgot password? +
+
+ ) +} diff --git a/apps/web/src/features/auth/pages/ResetConfirmPage.tsx b/apps/web/src/features/auth/pages/ResetConfirmPage.tsx new file mode 100644 index 0000000..8d38d7f --- /dev/null +++ b/apps/web/src/features/auth/pages/ResetConfirmPage.tsx @@ -0,0 +1,66 @@ +import { useState, type FormEvent } from 'react' +import { Link } from 'react-router-dom' + +import { ApiError } from '../../../lib/apiClient' +import { useConfirmPasswordReset } from '../hooks/useAuthMutations' +import { readTokenFromHash } from '../utils/hashToken' + +export function ResetConfirmPage() { + const [newPassword, setNewPassword] = useState('') + const confirm = useConfirmPasswordReset() + const token = readTokenFromHash(window.location.hash) + + function onSubmit(e: FormEvent) { + e.preventDefault() + if (token === null) return + confirm.mutate({ token, newPassword }) + } + + if (token === null) return

Missing reset link.

+ + if (confirm.isSuccess) { + return ( +

+ Password reset. Log in +

+ ) + } + + // §10 idempotency table: a retry after success returns 400 invalid_or_used — the UI + // reads that as "already reset", not as a fresh error. + const alreadyUsed = confirm.error instanceof ApiError && confirm.error.code === 'invalid_or_used' + + return ( +
+

Choose a new password

+ setNewPassword(e.target.value)} + className="rounded border border-neutral-300 px-3 py-2 dark:border-neutral-700 dark:bg-neutral-900" + /> + {confirm.isError && ( +

+ {alreadyUsed ? ( + <> + This link was already used. Log in + + ) : ( + 'That link is invalid or expired.' + )} +

+ )} + +
+ ) +} diff --git a/apps/web/src/features/auth/pages/ResetPage.test.tsx b/apps/web/src/features/auth/pages/ResetPage.test.tsx new file mode 100644 index 0000000..47602f7 --- /dev/null +++ b/apps/web/src/features/auth/pages/ResetPage.test.tsx @@ -0,0 +1,22 @@ +import { screen } from '@testing-library/react' +import { afterEach, describe, expect, it } from 'vitest' + +import { renderWithProviders } from '../../../test/renderWithProviders' +import { ResetPage } from './ResetPage' + +afterEach(() => { + window.location.hash = '' +}) + +describe('ResetPage', () => { + it('shows the request form when the URL has no token fragment', () => { + renderWithProviders() + expect(screen.getByRole('heading', { name: 'Reset password' })).toBeInTheDocument() + }) + + it('shows the confirm form when the URL carries a #t= token', () => { + window.location.hash = '#t=sometoken' + renderWithProviders() + expect(screen.getByRole('heading', { name: 'Choose a new password' })).toBeInTheDocument() + }) +}) diff --git a/apps/web/src/features/auth/pages/ResetPage.tsx b/apps/web/src/features/auth/pages/ResetPage.tsx new file mode 100644 index 0000000..a4d6e25 --- /dev/null +++ b/apps/web/src/features/auth/pages/ResetPage.tsx @@ -0,0 +1,10 @@ +import { readTokenFromHash } from '../utils/hashToken' +import { ResetConfirmPage } from './ResetConfirmPage' +import { ResetRequestPage } from './ResetRequestPage' + +// ARCHITECTURE §9: the email link is `/reset#t=` — request and confirm share +// one route, split by whether the fragment carries a token. +export function ResetPage() { + const hasToken = readTokenFromHash(window.location.hash) !== null + return hasToken ? : +} diff --git a/apps/web/src/features/auth/pages/ResetRequestPage.tsx b/apps/web/src/features/auth/pages/ResetRequestPage.tsx new file mode 100644 index 0000000..9060fbf --- /dev/null +++ b/apps/web/src/features/auth/pages/ResetRequestPage.tsx @@ -0,0 +1,38 @@ +import { useState, type FormEvent } from 'react' + +import { useRequestPasswordReset } from '../hooks/useAuthMutations' + +export function ResetRequestPage() { + const [email, setEmail] = useState('') + const resetRequest = useRequestPasswordReset() + + function onSubmit(e: FormEvent) { + e.preventDefault() + resetRequest.mutate(email) + } + + if (resetRequest.isSuccess) { + return

If that email has an account, a reset link is on its way.

+ } + + return ( +
+

Reset password

+ setEmail(e.target.value)} + className="rounded border border-neutral-300 px-3 py-2 dark:border-neutral-700 dark:bg-neutral-900" + /> + +
+ ) +} diff --git a/apps/web/src/features/auth/pages/SignupPage.tsx b/apps/web/src/features/auth/pages/SignupPage.tsx new file mode 100644 index 0000000..f5ec129 --- /dev/null +++ b/apps/web/src/features/auth/pages/SignupPage.tsx @@ -0,0 +1,61 @@ +import { useState, type FormEvent } from 'react' +import { Link } from 'react-router-dom' + +import { ApiError } from '../../../lib/apiClient' +import { useSignup } from '../hooks/useAuthMutations' + +export function SignupPage() { + const [email, setEmail] = useState('') + const [password, setPassword] = useState('') + const signup = useSignup() + + function onSubmit(e: FormEvent) { + e.preventDefault() + signup.mutate({ email, password }) + } + + if (signup.isSuccess) { + return

Check your email to verify your account.

+ } + + return ( +
+

Sign up

+ setEmail(e.target.value)} + className="rounded border border-neutral-300 px-3 py-2 dark:border-neutral-700 dark:bg-neutral-900" + /> + setPassword(e.target.value)} + className="rounded border border-neutral-300 px-3 py-2 dark:border-neutral-700 dark:bg-neutral-900" + /> + {signup.isError && ( +

+ {signup.error instanceof ApiError && signup.error.code === 'common_password' + ? 'That password is too common — please choose another.' + : 'Something went wrong. Please try again.'} +

+ )} + +

+ Already have an account? Log in +

+
+ ) +} diff --git a/apps/web/src/features/auth/pages/VerifyPage.tsx b/apps/web/src/features/auth/pages/VerifyPage.tsx new file mode 100644 index 0000000..0f9fa61 --- /dev/null +++ b/apps/web/src/features/auth/pages/VerifyPage.tsx @@ -0,0 +1,33 @@ +import { useEffect, useRef } from 'react' +import { Link } from 'react-router-dom' + +import { useVerifyEmail } from '../hooks/useAuthMutations' +import { readTokenFromHash } from '../utils/hashToken' + +export function VerifyPage() { + const verify = useVerifyEmail() + const triggered = useRef(false) + + useEffect(() => { + if (triggered.current) return + triggered.current = true + const token = readTokenFromHash(window.location.hash) + if (token === null) return + verify.mutate(token) + // Referrer-Policy: no-referrer is set server-side on this page; the token never + // needs to leave the fragment again once consumed. + }, [verify]) + + const token = readTokenFromHash(window.location.hash) + + if (token === null) return

Missing verification link.

+ if (verify.isPending || verify.isIdle) return

Verifying…

+ if (verify.isSuccess) { + return ( +

+ Email verified. Log in +

+ ) + } + return

That link is invalid or expired.

+} diff --git a/apps/web/src/features/auth/routes.tsx b/apps/web/src/features/auth/routes.tsx new file mode 100644 index 0000000..cc33474 --- /dev/null +++ b/apps/web/src/features/auth/routes.tsx @@ -0,0 +1,13 @@ +import type { RouteObject } from 'react-router-dom' + +import { LoginPage } from './pages/LoginPage' +import { ResetPage } from './pages/ResetPage' +import { SignupPage } from './pages/SignupPage' +import { VerifyPage } from './pages/VerifyPage' + +export const authRoutes: RouteObject[] = [ + { path: '/signup', element: }, + { path: '/verify', element: }, + { path: '/login', element: }, + { path: '/reset', element: }, +] diff --git a/apps/web/src/features/auth/types.ts b/apps/web/src/features/auth/types.ts new file mode 100644 index 0000000..0f0e6bd --- /dev/null +++ b/apps/web/src/features/auth/types.ts @@ -0,0 +1,3 @@ +export interface LoginResponse { + accessToken: string +} diff --git a/apps/web/src/features/auth/utils/hashToken.test.ts b/apps/web/src/features/auth/utils/hashToken.test.ts new file mode 100644 index 0000000..b24b2b6 --- /dev/null +++ b/apps/web/src/features/auth/utils/hashToken.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from 'vitest' + +import { readTokenFromHash } from './hashToken' + +describe('readTokenFromHash', () => { + it('reads the t param from a fragment', () => { + expect(readTokenFromHash('#t=abc123')).toBe('abc123') + }) + + it('returns null when there is no t param', () => { + expect(readTokenFromHash('')).toBeNull() + expect(readTokenFromHash('#other=1')).toBeNull() + }) +}) diff --git a/apps/web/src/features/auth/utils/hashToken.ts b/apps/web/src/features/auth/utils/hashToken.ts new file mode 100644 index 0000000..f772e51 --- /dev/null +++ b/apps/web/src/features/auth/utils/hashToken.ts @@ -0,0 +1,6 @@ +// ARCHITECTURE §9: verify/reset links carry the token in the URL fragment (`#t=`) +// so it never reaches the server or Referer. Read once on mount, then the page POSTs it. +export function readTokenFromHash(hash: string): string | null { + const params = new URLSearchParams(hash.replace(/^#/, '')) + return params.get('t') +} diff --git a/apps/web/src/features/curator/api.ts b/apps/web/src/features/curator/api.ts new file mode 100644 index 0000000..2c0737b --- /dev/null +++ b/apps/web/src/features/curator/api.ts @@ -0,0 +1,31 @@ +import { curatorFetch } from './lib/curatorFetch' +import type { BlindspotFeedResponse, NewClusterReport, NewRating, NewSource } from './types' + +export function onboardSource(input: NewSource): Promise<{ id: string }> { + return curatorFetch<{ id: string }>('/api/curator/sources', { method: 'POST', body: input }) +} + +export function createTopic(name: string, keywords: string[], createdBy: string): Promise<{ id: string }> { + return curatorFetch<{ id: string }>('/api/curator/topics', { method: 'POST', body: { name, keywords, createdBy } }) +} + +export function hideArticle(id: string, reason: string): Promise { + return curatorFetch(`/api/curator/articles/${id}/hide`, { method: 'POST', body: { reason } }) +} + +export function hideStory(id: string): Promise { + return curatorFetch(`/api/curator/stories/${id}/hide`, { method: 'POST' }) +} + +export function reportClusterError(input: NewClusterReport): Promise<{ id: string }> { + return curatorFetch<{ id: string }>('/api/curator/cluster-reports', { method: 'POST', body: input }) +} + +export function submitRating(sourceId: string, input: NewRating): Promise { + return curatorFetch(`/api/curator/sources/${sourceId}/ratings`, { method: 'POST', body: input }) +} + +export function getBlindspotFeed(cursor?: string): Promise { + const query = cursor === undefined ? '' : `?cursor=${encodeURIComponent(cursor)}` + return curatorFetch(`/api/curator/blindspot-feed${query}`) +} diff --git a/apps/web/src/features/curator/components/CuratorKeyGate.tsx b/apps/web/src/features/curator/components/CuratorKeyGate.tsx new file mode 100644 index 0000000..df87b88 --- /dev/null +++ b/apps/web/src/features/curator/components/CuratorKeyGate.tsx @@ -0,0 +1,51 @@ +import { useState, type FormEvent, type ReactNode } from 'react' + +import { getBlindspotFeed } from '../api' +import { CuratorApiError } from '../lib/curatorFetch' +import { useCuratorStore } from '../store' + +export function CuratorKeyGate({ children }: { children: ReactNode }) { + const { apiKey, clearApiKey } = useCuratorStore() + const [input, setInput] = useState('') + const [checking, setChecking] = useState(false) + const [error, setError] = useState(null) + + if (apiKey !== null) return children + + async function onSubmit(e: FormEvent) { + e.preventDefault() + setChecking(true) + setError(null) + useCuratorStore.getState().setApiKey(input) + try { + await getBlindspotFeed() // cheap, already-registered route — validates the key for real + } catch (err) { + clearApiKey() + setError(err instanceof CuratorApiError && err.status === 401 ? 'Invalid curator key.' : 'Could not reach the API.') + } finally { + setChecking(false) + } + } + + return ( +
+

Curator access

+ setInput(e.target.value)} + className="rounded border border-neutral-300 px-3 py-2 dark:border-neutral-700 dark:bg-neutral-900" + /> + {error !== null &&

{error}

} + +
+ ) +} diff --git a/apps/web/src/features/curator/hooks/useCuratorMutations.ts b/apps/web/src/features/curator/hooks/useCuratorMutations.ts new file mode 100644 index 0000000..efae707 --- /dev/null +++ b/apps/web/src/features/curator/hooks/useCuratorMutations.ts @@ -0,0 +1,48 @@ +import { useInfiniteQuery, useMutation } from '@tanstack/react-query' + +import { + createTopic, + getBlindspotFeed, + hideArticle, + hideStory, + onboardSource, + reportClusterError, + submitRating, +} from '../api' +import type { NewClusterReport, NewRating, NewSource } from '../types' + +export function useOnboardSource() { + return useMutation({ mutationFn: (input: NewSource) => onboardSource(input) }) +} + +export function useCreateTopic() { + return useMutation({ + mutationFn: ({ name, keywords, createdBy }: { name: string; keywords: string[]; createdBy: string }) => + createTopic(name, keywords, createdBy), + }) +} + +export function useHideArticle() { + return useMutation({ mutationFn: ({ id, reason }: { id: string; reason: string }) => hideArticle(id, reason) }) +} + +export function useHideStory() { + return useMutation({ mutationFn: (id: string) => hideStory(id) }) +} + +export function useReportClusterError() { + return useMutation({ mutationFn: (input: NewClusterReport) => reportClusterError(input) }) +} + +export function useSubmitRating() { + return useMutation({ mutationFn: ({ sourceId, input }: { sourceId: string; input: NewRating }) => submitRating(sourceId, input) }) +} + +export function useBlindspotFeed() { + return useInfiniteQuery({ + queryKey: ['curator', 'blindspot'], + queryFn: ({ pageParam }: { pageParam: string | undefined }) => getBlindspotFeed(pageParam), + initialPageParam: undefined as string | undefined, + getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined, + }) +} diff --git a/apps/web/src/features/curator/index.ts b/apps/web/src/features/curator/index.ts new file mode 100644 index 0000000..60b2a6d --- /dev/null +++ b/apps/web/src/features/curator/index.ts @@ -0,0 +1 @@ +export { curatorRoutes } from './routes' diff --git a/apps/web/src/features/curator/lib/curatorFetch.test.ts b/apps/web/src/features/curator/lib/curatorFetch.test.ts new file mode 100644 index 0000000..7bae2c3 --- /dev/null +++ b/apps/web/src/features/curator/lib/curatorFetch.test.ts @@ -0,0 +1,47 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { useCuratorStore } from '../store' +import { CuratorApiError, curatorFetch } from './curatorFetch' + +function jsonResponse(status: number, body: unknown): Response { + return new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } }) +} + +beforeEach(() => { + useCuratorStore.setState({ apiKey: null }) +}) + +afterEach(() => { + vi.restoreAllMocks() +}) + +describe('curatorFetch', () => { + it('attaches the X-Curator-Key header when a key is set', async () => { + useCuratorStore.setState({ apiKey: 'secret' }) + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(jsonResponse(200, { items: [] })) + await curatorFetch('/api/curator/blindspot-feed') + const headers = fetchSpy.mock.calls[0][1]?.headers as Record + expect(headers['X-Curator-Key']).toBe('secret') + }) + + it('throws a CuratorApiError on a non-2xx response', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue(jsonResponse(401, { error: 'missing_curator_key' })) + await expect(curatorFetch('/api/curator/sources', { method: 'POST', body: {} })).rejects.toMatchObject({ + status: 401, + code: 'missing_curator_key', + }) + }) + + it('treats 204 as no body', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(null, { status: 204 })) + expect(await curatorFetch('/api/curator/stories/x/hide', { method: 'POST' })).toBeUndefined() + }) +}) + +describe('CuratorApiError', () => { + it('carries status and code', () => { + const err = new CuratorApiError(400, 'validation_error') + expect(err.status).toBe(400) + expect(err.code).toBe('validation_error') + }) +}) diff --git a/apps/web/src/features/curator/lib/curatorFetch.ts b/apps/web/src/features/curator/lib/curatorFetch.ts new file mode 100644 index 0000000..73c5738 --- /dev/null +++ b/apps/web/src/features/curator/lib/curatorFetch.ts @@ -0,0 +1,44 @@ +import { useCuratorStore } from '../store' + +const BASE_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:3000' + +export class CuratorApiError extends Error { + status: number + code: string + constructor(status: number, code: string) { + super(code) + this.status = status + this.code = code + } +} + +interface RequestOptions { + method?: 'GET' | 'POST' + body?: unknown +} + +export async function curatorFetch(path: string, options: RequestOptions = {}): Promise { + const apiKey = useCuratorStore.getState().apiKey + const method = options.method ?? 'GET' + const headers: Record = {} + // Curator routes are CSRF-exempt server-side (app.ts), but every other mutating call in + // this app sends Content-Type unconditionally (see apiClient.ts) — matching that here + // keeps the two clients consistent rather than relying on the exemption staying in place. + if (method !== 'GET') headers['Content-Type'] = 'application/json' + if (apiKey !== null) headers['X-Curator-Key'] = apiKey + + const res = await fetch(`${BASE_URL}${path}`, { + method, + headers, + // Fastify rejects Content-Type: application/json paired with a truly empty body + // (FST_ERR_CTP_EMPTY_JSON_BODY) — a bodyless mutation sends the minimal valid JSON `{}`. + body: options.body !== undefined ? JSON.stringify(options.body) : method === 'GET' ? undefined : '{}', + }) + + if (res.status === 204) return undefined as T + if (!res.ok) { + const body = await res.json().catch(() => ({ error: 'unknown_error' })) + throw new CuratorApiError(res.status, typeof body.error === 'string' ? body.error : 'unknown_error') + } + return (await res.json()) as T +} diff --git a/apps/web/src/features/curator/pages/CuratorBlindspotPage.tsx b/apps/web/src/features/curator/pages/CuratorBlindspotPage.tsx new file mode 100644 index 0000000..bef05e2 --- /dev/null +++ b/apps/web/src/features/curator/pages/CuratorBlindspotPage.tsx @@ -0,0 +1,52 @@ +import { Link } from 'react-router-dom' + +import { EmptyState, ErrorState, LoadingState, LoadMoreButton } from '../../../components/QueryStates' +import { useBlindspotFeed } from '../hooks/useCuratorMutations' + +// PRODUCT §4.7 pinned phrasing: "Little coverage from X-leaning sources", never +// "Blindspot: X" (readers parse that both ways). +function sideLabel(side: 'L' | 'R'): string { + return side === 'L' ? 'Little coverage from left-leaning sources' : 'Little coverage from right-leaning sources' +} + +export function CuratorBlindspotPage() { + const { data, error, isLoading, isFetchingNextPage, fetchNextPage, hasNextPage } = useBlindspotFeed() + + if (isLoading) return + if (error) return + + const items = data?.pages.flatMap((p) => p.items) ?? [] + + return ( +
+
+

Blindspot feed

+

+ Curator-only until a calibration round meets PRODUCT §4.9's release bars. +

+
+ {items.length === 0 ? ( + + ) : ( +
    + {items.map((item) => { + const total = item.countedLeft + item.countedCenter + item.countedRight + return ( +
  • + + {item.title} + +

    {sideLabel(item.side)}

    +

    + {total} rated sources covered this: {item.countedLeft} left-leaning, {item.countedCenter} center,{' '} + {item.countedRight} right-leaning +

    +
  • + ) + })} +
+ )} + {hasNextPage && fetchNextPage()} loading={isFetchingNextPage} />} +
+ ) +} diff --git a/apps/web/src/features/curator/pages/CuratorLayout.tsx b/apps/web/src/features/curator/pages/CuratorLayout.tsx new file mode 100644 index 0000000..5ba810a --- /dev/null +++ b/apps/web/src/features/curator/pages/CuratorLayout.tsx @@ -0,0 +1,35 @@ +import { NavLink, Outlet } from 'react-router-dom' + +import { CuratorKeyGate } from '../components/CuratorKeyGate' + +const TABS = [ + { to: '/curator/sources', label: 'Sources' }, + { to: '/curator/topics', label: 'Topics' }, + { to: '/curator/moderation', label: 'Moderation' }, + { to: '/curator/blindspot', label: 'Blindspot' }, +] + +export function CuratorLayout() { + return ( + +
+ + +
+
+ ) +} diff --git a/apps/web/src/features/curator/pages/CuratorModerationPage.tsx b/apps/web/src/features/curator/pages/CuratorModerationPage.tsx new file mode 100644 index 0000000..add57e6 --- /dev/null +++ b/apps/web/src/features/curator/pages/CuratorModerationPage.tsx @@ -0,0 +1,111 @@ +import { useState, type FormEvent } from 'react' + +import { useHideArticle, useHideStory, useReportClusterError } from '../hooks/useCuratorMutations' +import type { ClusterReportKind } from '../types' + +const inputClass = 'rounded border border-neutral-300 px-3 py-2 dark:border-neutral-700 dark:bg-neutral-900' + +function HideArticleForm() { + const [id, setId] = useState('') + const [reason, setReason] = useState<'takedown' | 'spam' | 'duplicate' | 'canonical_duplicate'>('takedown') + const hideArticle = useHideArticle() + + function onSubmit(e: FormEvent) { + e.preventDefault() + hideArticle.mutate({ id, reason }) + } + + return ( +
+

Hide an article

+ setId(e.target.value)} className={inputClass} /> + + {hideArticle.isSuccess &&

Article hidden.

} + {hideArticle.isError &&

Couldn't hide that article.

} + +
+ ) +} + +function HideStoryForm() { + const [id, setId] = useState('') + const hideStory = useHideStory() + + function onSubmit(e: FormEvent) { + e.preventDefault() + hideStory.mutate(id) + } + + return ( +
+

Hide a story

+ setId(e.target.value)} className={inputClass} /> + {hideStory.isSuccess &&

Story hidden from public feeds.

} + {hideStory.isError &&

Couldn't hide that story.

} + +
+ ) +} + +function ClusterReportForm() { + const [kind, setKind] = useState('wrong_merge') + const [storyId, setStoryId] = useState('') + const [articleId, setArticleId] = useState('') + const [otherStoryId, setOtherStoryId] = useState('') + const [reportedBy, setReportedBy] = useState('') + const report = useReportClusterError() + + function onSubmit(e: FormEvent) { + e.preventDefault() + report.mutate({ + kind, + storyId, + reportedBy, + articleId: kind === 'wrong_merge' ? articleId : undefined, + otherStoryId: kind === 'should_merge' ? otherStoryId : undefined, + }) + } + + return ( +
+

Report a clustering error

+ + setStoryId(e.target.value)} className={inputClass} /> + {kind === 'wrong_merge' && ( + setArticleId(e.target.value)} className={inputClass} /> + )} + {kind === 'should_merge' && ( + setOtherStoryId(e.target.value)} className={inputClass} /> + )} + setReportedBy(e.target.value)} className={inputClass} /> + {report.isSuccess &&

Report recorded: {report.data.id}

} + {report.isError &&

Couldn't record that report.

} + +
+ ) +} + +export function CuratorModerationPage() { + return ( +
+

Moderation

+ + + +
+ ) +} diff --git a/apps/web/src/features/curator/pages/CuratorSourcesPage.tsx b/apps/web/src/features/curator/pages/CuratorSourcesPage.tsx new file mode 100644 index 0000000..ca810e7 --- /dev/null +++ b/apps/web/src/features/curator/pages/CuratorSourcesPage.tsx @@ -0,0 +1,140 @@ +import { useState, type FormEvent } from 'react' + +import { CuratorApiError } from '../lib/curatorFetch' +import { useOnboardSource, useSubmitRating } from '../hooks/useCuratorMutations' +import type { BiasRating, Confidence } from '../types' + +const inputClass = 'rounded border border-neutral-300 px-3 py-2 dark:border-neutral-700 dark:bg-neutral-900' + +function OnboardSourceForm() { + const [form, setForm] = useState({ name: '', domain: '', countryCode: 'IN', language: 'en', feedTimezone: 'Asia/Kolkata' }) + const onboard = useOnboardSource() + + function onSubmit(e: FormEvent) { + e.preventDefault() + onboard.mutate(form) + } + + return ( +
+

Onboard a source

+ setForm({ ...form, name: e.target.value })} className={inputClass} /> + setForm({ ...form, domain: e.target.value })} className={inputClass} /> + setForm({ ...form, countryCode: e.target.value })} className={inputClass} /> + setForm({ ...form, language: e.target.value })} className={inputClass} /> + setForm({ ...form, feedTimezone: e.target.value })} + className={inputClass} + /> + {onboard.isError &&

Couldn't onboard that source.

} + {onboard.isSuccess &&

Source created: {onboard.data.id}

} + +
+ ) +} + +function RateSourceForm() { + const [sourceId, setSourceId] = useState('') + const [biasRating, setBiasRating] = useState('') + const [factuality, setFactuality] = useState<'high' | 'mixed' | 'low'>('high') + const [confidence, setConfidence] = useState('') + const [ratedBy, setRatedBy] = useState('') + const [secondRater, setSecondRater] = useState('') + const [evidenceText, setEvidenceText] = useState('') + const [rubricScoresJson, setRubricScoresJson] = useState('') + const submitRating = useSubmitRating() + + function onSubmit(e: FormEvent) { + e.preventDefault() + const evidence = evidenceText.split('\n').map((l) => l.trim()).filter((l) => l.length > 0) + let rubricScores: Record | null = null + if (rubricScoresJson.trim() !== '') { + try { + rubricScores = JSON.parse(rubricScoresJson) + } catch { + return + } + } + submitRating.mutate({ + sourceId, + input: { + spectrum: biasRating === '' ? null : 'IN', + biasRating: biasRating === '' ? null : biasRating, + factuality, + confidence: confidence === '' ? null : confidence, + method: 'manual-curation', + ratedBy, + secondRater: secondRater === '' ? null : secondRater, + rubricScores, + evidence: evidence.length > 0 ? evidence : null, + }, + }) + } + + return ( +
+

Submit a rating

+

+ Manual-curation rubric v1 (PRODUCT §5.2) — two distinct raters, rubric scores, and at least 3 evidence items. +

+ setSourceId(e.target.value)} className={inputClass} /> + + + + setRatedBy(e.target.value)} className={inputClass} /> + setSecondRater(e.target.value)} className={inputClass} /> +