From 0fed6cf83d1a991c6ce10b262460a2e232814e98 Mon Sep 17 00:00:00 2001 From: Duy Nguyen Date: Tue, 20 Oct 2026 17:21:19 +0700 Subject: [PATCH 1/7] build: vendor the @dewee/identity SDK tarball until it is published The Dewee identity server SDK is not on npm yet. Pack 0.1.0-beta.0 from the dewee repository (packages/dewee-identity at nextlevelbuilder/dewee dev) into vendor/ and use it as a file: dev dependency for the tests. For users it is an optional peer dependency, needed only when the handler signs users in, so the published package never points at a local file. Co-Authored-By: Claude Opus 5.5 --- packages/dewee-webchat/package.json | 5 +++++ pnpm-lock.yaml | 17 +++++++++++++++++ vendor/dewee-identity-0.1.0-beta.0.tgz | Bin 0 -> 21664 bytes 3 files changed, 22 insertions(+) create mode 100644 vendor/dewee-identity-0.1.0-beta.0.tgz diff --git a/packages/dewee-webchat/package.json b/packages/dewee-webchat/package.json index f2c0cd3..09e4fec 100644 --- a/packages/dewee-webchat/package.json +++ b/packages/dewee-webchat/package.json @@ -70,10 +70,14 @@ "typecheck": "tsc --noEmit" }, "peerDependencies": { + "@dewee/identity": ">=0.1.0-beta.0 <1", "react": ">=18", "vue": ">=3.3" }, "peerDependenciesMeta": { + "@dewee/identity": { + "optional": true + }, "react": { "optional": true }, @@ -82,6 +86,7 @@ } }, "devDependencies": { + "@dewee/identity": "file:../../vendor/dewee-identity-0.1.0-beta.0.tgz", "@types/node": "^22.15.0", "@types/react": "^19.1.0", "@types/ws": "^8.18.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 69d5b5c..9aa2f83 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -150,6 +150,9 @@ importers: packages/dewee-webchat: devDependencies: + '@dewee/identity': + specifier: file:../../vendor/dewee-identity-0.1.0-beta.0.tgz + version: file:vendor/dewee-identity-0.1.0-beta.0.tgz '@types/node': specifier: ^22.15.0 version: 22.20.5 @@ -641,6 +644,11 @@ packages: resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} engines: {node: '>=12'} + '@dewee/identity@file:vendor/dewee-identity-0.1.0-beta.0.tgz': + resolution: {integrity: sha512-IGFRWs/LMQDz61DZBZifRuMQcjygZJcvo1PoP1FUYYRvw+YM5B/cryZyUK/Sg8tGxFxUrpduk9dEjHC/GC573w==, tarball: file:vendor/dewee-identity-0.1.0-beta.0.tgz} + version: 0.1.0-beta.0 + engines: {node: '>=20.3'} + '@dotenvx/dotenvx@2.34.2': resolution: {integrity: sha512-vU83L7W77P4LPpzbkicEraleqEwh6a1mcyRvzZA4D/786mYAQH3yad7be86jx9voAuyWx1QhDcJ8h+Dzzt++ng==} hasBin: true @@ -2483,6 +2491,9 @@ packages: resolution: {integrity: sha512-N3yCS/NegsOBokc8GAdM8UcmfsKiSS8cipheD/nivzr700H+nsMOxJjQnvwOcRYVuFkdH0wGUvW2WbXGmrZGbQ==} engines: {node: '>=14'} + jose@6.2.13: + resolution: {integrity: sha512-7TIg10ZE1x82YediN+lcrPNvGMKge9yFHQD+Qho/b8y2adfugU3AsvvHfxV0fzyFllT6l9xCjf4RWY9g/Z9PJQ==} + joycon@3.1.1: resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} engines: {node: '>=10'} @@ -4409,6 +4420,10 @@ snapshots: dependencies: '@jridgewell/trace-mapping': 0.3.9 + '@dewee/identity@file:vendor/dewee-identity-0.1.0-beta.0.tgz': + dependencies: + jose: 6.2.13 + '@dotenvx/dotenvx@2.34.2': dependencies: '@dotenvx/primitives': 3.3.0 @@ -6181,6 +6196,8 @@ snapshots: optionalDependencies: '@pkgjs/parseargs': 0.11.0 + jose@6.2.13: {} + joycon@3.1.1: {} js-tokens@9.0.1: {} diff --git a/vendor/dewee-identity-0.1.0-beta.0.tgz b/vendor/dewee-identity-0.1.0-beta.0.tgz new file mode 100644 index 0000000000000000000000000000000000000000..03a8dc287094645f2e176274f6045491e5f377ea GIT binary patch literal 21664 zcmV)aK&rnViwFP!000003hjOUcH73a@BN!kF=4$LvH>Z{Uuh}JE+a9qVp;MPsW>@~ z4*?NF5*7$B7|@a`bFK3b_ZjYk-6y$g?>#e^0S1(ux^8=pxw;w)7|i$V*`L3?3?>)B zjBkaJ$hQI^c$PjSWx3{;;|J`}K^XL)Ve(>n=_Rh}Z$J-Ct z_Rhndhw%Or_M!H-5_ypQukF9Pt@7ahO@7g0nPxe=Vf2q5U%&9!Fq$Plz8f7)vB zD{OO;CL(A3;c##|Iy^ZZkA8VI7_vQf(_oA-!G(y@q{o^ezvR3l|NiFPW#06##5_pC zbUM`~xvCfAqNNvF1SkBRm-Pn{V4REk|iG<05DJcw+N;wBXic1d|D0=7P2O)sl5s6y)vd zq`d6^JT@j~bTl3g_D_xvphpk3x2eGcJ`Gl}tHCbot7n1*8D}C&W-(_g!FO4}#2i|J z!YmSuC+TW7ulC_!@O}U6XjI#e$Fdtks0{}G{AGXN=#1c#jOW(aSAq-2U=NR>+S9>k zd^9-zVe}jZd0#Ee+1cU2_*H*28k`=pJ+}3qZ?-#s2|ClaH-BDt^uHh8;om#=*Y~y} zRqyfP(HQDxd#t%Fe;vpFsb5olcy##vVE>o>qrrIa)8KeyZxXQ*OP++VJ2DJedc{jE8#rjOLsLt1#lpgtG(KtSk~@#X}Y(ELbk%XcFXjAbi}) zy(h*)7KKlJb7rTjWP&e@LVa0#%XoGew&i)tc)rRKRr$Ret57#jkJ3w??FWLl+G~He zvBpmS1QaI7xnKb<6blr!X~j^!GYJF_+bl>zmhma9KW>O5im|-2%aUB2t)j5?K8RP` zV@a^!s-w}AwessFPp1suvaj~`SX1O#l+2p!!w2@2xn;VrHL035yrfU|_V)A!Q1!wu zpJ(Z1nR2%LdpFR5HCwDi&d#|Utl!?n57*}WVg2@W$;oOkd%nc#{2iar0fEC|9+WWU z!ear8qdbo}gTYO5p7mIgCLO+5=GPtza<)iC&h9^Y#LlmCE<9)fW;#ik|5x8*B89!f z7eN%WAPh4u1e*j2OE}Q@3x3T4IFebnv0)j8)XkRcu45I{K+5YNpLg>tTD00Y=gsEr zv)xqnMMCuD4me!~zpeDRJ(i?-Wo4VL-6$)C9avu1 zLm)0$3u#Q|SXFvaSyXy#uc`6eoL_jn^{d#TBPbiX!%WDQt>uTqA)imuWE#y@84p>S z;0uFPd5m8LlRUn5P6yTCB3>ezB|MXK3d#brBIt}HAq(PJnnn41(M389kLeJ_Ja~^Z zEfQ>zhO3w(NejevGAWJ{)E*_X7yPtrg~;{$IRZC6s9sM=9a8K_2y^#>_4e89A>i{t^wJ;I-#Yl_qC`pKdm5#Eh2Ju z@21k~nn*}n-6O@Ez;9~{x0=a1VT)Ar*MgmmzVG~*s!$9!a$pzyTAu=VA-RYa5RH(- z#DwjxZbqJr8~Chu*Qxa2K=fGiIdZ~mZLTNn&v&((v)GPUW^a!rt2l;rl#BgNZs2B$ zw5w^q>W0?(-(maO+p)O9<#YDq>lf7>X*75dg_SL*p5OUSvpbP+9s<>YD)Kp8DnUM( z%LNAF5+y|c;p~i8S!{QZrZ|Xa#T07-#hyLG-Uab&w>%p7qQWdW1)uIb5Un7d-Gy8i zO{=*@Ny%dqa33OqQfhff5Kwkt2zx*+e zS|y%)7N_SyJeo(MJISt>dD^9lfXTGjM-cg8-zE)R$x%GNjPg12 zBO!c``N1mmS*uQJXnV{Tt8*W@M?VU3_=I0AeY*J6nEZEn*e`uy z-`W9LhdysoHR9=|NV8(f?Vedu9ow^HE$p9Fz++A8Z@VtZkL{H>N~}h5duDOP;O>gIVoYq>R6)ty>4voI)+a+NUHA#xJ9hRrS0@Ss#_8kO%5(m*jfI@6-Z)3HI==zb1-mKxB z@B=)s720pSDj50VVZqEl+PQx>7O82F1AVgTw!cfU4V~!$WsQSt9-6HLq+lqT%F8% z>NT~azIjH^JVxp@=x<5A=G;V^I^MQ=B)1!tliLmd*sZG!7roqx(m0Vg^|LIv?nVOt zD~TX}h7{^6;{nv1UAJ1I&Gw$EdrQccBp+OEV#9RWos-9t9`;b#2pvKx59=1@9NP0} z%n*Ig_Sl^AJ)pM^h0Rjpcu+pFM@Nfd9@e5OE@I)@%8Dx9*3pgI z7f!3J02qgUE-A4TC?+p9t2r7i<=1GfV~Xzd3PG8Y1UH2`%;dT9RxXVwcFhp&qvXMx7uur z?QCyv%W5Pujky=C8UZaM$KP+oqs||+zyamQ1>&@V58D&@_&JfJ$H^AZ8rC`^*%Omq)G)r0+ z{2GOgf3~sn%dhfVn)ZKfzWuaq++zQKRJH#AyN;V#y~C%lzTN;COU6`ei&gJv})c@1Go?&%zs|rA__zbnw5=4o?RM(h;F} zadde6qW8aA zubZ3WpZZ6K2RQh_P!I9=WHkQ%J&xeLlBkKtNzjHpnveoc<}S# za5OY#@8zqL(cpOhm+_0iFJswI-RslA*|3-(*_-j`&J9{z{*%$Q&~Ua&v+kBSN3*HeoJF^B~E#0VZwJ$BBgX@(L^l!%-MA?)3t za2vsSuTJ^!;DvL#Ng+82;u!RV$|?_5sz`u02sMOU-Y)1v=kV+@5Olnt8Eet{r$3y% z92~1vQzs9`0UyO_5b_X?P{7W!^b(B9>@a8ZG!BJUR}#sfbB@epwo1T;2SfwZbw#to z!Rb$f)A7mi(J!SI)qG6SRUD!s_nf07I0qZY!ST>zqobjxlnEp*3m{bB7?ZA~w+*&3 zI_)11U!9zeXa;LqqM7F*i5LMpPs3}yh+wG4UI6=;i9ATc>g1gchObVJhsM!6vxzApdH^2I682CgyRT zj>Cm|n(;il9>+n>GxcbaCV4Q)$3eJ=lA;`FDxk*`ba_!jeLYWMh=LZ{=>J_tC|TuO z$9Q&dI5^&??cap)!8pL#CSCF@$W#0`YUJoI-M1jux;lJA3mTk*QU!h5G!X>4*kyxc z7Dr<4k^Gj!xP$VCle|X5QdES+r z2y8DRa_FuQuzji4gQAso)P+~mgseV?dne83JR^SN z)tY&W%7(BsXp1Db!a;C?*&XHq*c)a+lvtn33s-&)z4)387DC_}kxmXZW>P_gwMDa} zmjG~yvUU*)t3V(_ZM`3O^VI#5+r@A)2U(t;F}a-w&R+SN+) zCl!HRb_%DR;c4%h?vciV9Sp|9!N~A>bFoBi^`T-39N#&(1^pROt{AcTYAX8bD0lS6+g z_mEKPwTVMkiVjU7wdNzc?oV@`Njrpk90*keCn;M^@-%BjNn|-%h}DwQ)2`(AJ%)cx zf|b-_o63>m-=G=6GCi~W+R(G%76soq@UpB9x>+ho^C~OTW7n^xm>>(tzn(o~+of{m zB_j)0=FBKun!Mr@=d2nZ!t@rUi^*OWd_=Y)RiE^m$*a-|^4pqcnDM;47hC39X-B5& zcGWZ3cJ|_O&WRKWSPp4pVG9tbR+9&Epaq!jb!-tZx*ZRUC!`$-4M-(mdfR)KRCW8piq9Bg`hDH#&dw?j@d`{uM z;c-|jW1jvvJUMnvhppCzqtPo)iDzrp(@?!cdD(hNyT4~147-3l%Cdwy*j5qaQ1mJ% zr(Cpj7GB7Wy~5ucRODH3S@qbV?v<~a;&voQoeBBg z{qc{gW4hwXm&P?o-^!A0*It$F_bMIk8C`ey${52oCYg5W_dy(=2a^k=S@cLXEiNPQ z=P5nKi3C&f<>L$mezQs2oMXS`UpwyKVBtfb?zgtDIlqd2+`$@VH zFhkwHx~k|%fQLpY9im$z6dNpk7FGe3^W;5`(w-pm|f; z95B-d@nvu=P)ACK28d?vcI3xSe4nL@Q!bWiBGj?-aFRTOwyPrt-g%N2t3R~6x~L+m zd(0=1L#s?syL<13zJRb1^+|;vBhsiBWRDi`D@Ah)(uU9XO#jel_>9yCtmkrk*3~-d zE1EP3xk-DdXKYUwwQc{Zo~~0WXt~7#Dvg%@vV>&5ubB1as_+tfymt3UFHEUZ`i=7A z|El80$_f?&*7Y84;dPJQyD2^vaWvtrZ4W}y+G=&}?%1q&<(lOt$5C;n(d~dtE?!6Z zyw$WfnCg`#)lr;*>2lAMZ2SY4U8ai_p(Jr!v>Q1fgN&&IZ}NAJM%G{KzyF#)+x~k+lG!J=0B^ScZa@AO zY`(Vr_uHNOU+ll1A-75ZJQ7QW{`K4R(O9;oVcf0uTxSd=huGt($ z-j|MzbUBF0oG(yEQdo;JGCIexL#1B0(Sk8pE^ZUONhW8nnAUz6A6VN1Ga2hv=5z2UgLBZ-Y6sBJ3?V$94pnVcD z9N1G0fcI}2Z;ZrHU6b4VUnmlz>;jKd?RJ63SJjYKgHlu@`^(@gdpQmAuHGzyb3mrGKU;IUQIRflv7WdNwfz`>;tRXy6&GFi2 zYtNbp`fF`DQj;lL^mAzjMYm~-FKcChF42?)$3foou%m5*bn1KkI5=+UDhuChM`Y+- z)$xTpOkt$(%P&E4hd!;9$Jso9`{_IgSps>6oS$1R z>Fx?_lHjNU?w#6?g#H|C<2Y-zhO2r2BN^btjb#Ij(p9e`&vr3gh zRW+EL>#pj{cd|C;_bvbq`u^`#t61`Xl!W~1le>T4CjZ}mSoQzD|KRZ#`TukLq!98~ zjGWXLSx@>Cpqvcei0M>1VM(7WR6FmZCyqo*!bl} z2IL5%KH>mnYo|PnGCs-IJw>yjd-GJ}JnUd`9iQyzXHh9iF4v=M1s|RXo~^;ZL`0zk z#d*2^N)8%h07FaMp^>0@2&8v40}|Jc;v_Mgj`0Lsot9~o8$%q=WZ78HDReA3#SH zx@@VA{gdP4!TxA`IvAau9s^|AbB_c;eWavb&3Vvh`@{x0+F$a|mQC?*QFjF1dq zxID-~16?3o#BhyW6VC%GbAi4+opcF)FW3gzs^JZITap&M5>}sQ!FwJnRuLP8^$pGV zL~w(bs&7;zZBT9Nsr(mHYL~Dk?hYG7-xylT5;Txyk5h+d<1s9{;+Y5u?W#KM(`eFq zB}JZV<2Uc3ZnMX_cB#>{vpMb)+yB1g$!jTHq`m{cGLZ8(9sP+`y?OtSQ7zL%qf~>L6ZN(GlJSCr62VL-;CeWEA+B& zS2R@Ol%ihjFV)Mjiis(Gd>$ns;EFguRxe#2uPw3(uLV-bijB=AnVU^(H&M9WqNc#Q z)>lwn7@Oj({BA|3rGJ0ekJ*z zbUB~6ja%?v9)A01yQ2Sp^d4??@|PSES4=WrtZ5y@UtxbDDS~n zVAz)tm1JVjf#yr_0Y^{2j8Ay28A3a{*+cNy^_|+1T$4;@LQQDF<>+xfJ_hBOVWR>yfdA=+s z=eT!Mp0r9GP5VL9RqOiRjbpHuS^`l9WZcbVNTBOTg-8zHKm(Qm2-mDv#8wg}pAAqMut^q7VWG+Nq`@i|!G%hfKvo`DVNx5E6#yoZ zfQWML4w!Lh;9F6$TuCX{);Fm|g{X}3`XZxJULf+$s;o)HjEyaJ(c(9{syZ)yW$Zth zVZr*~F3{Zt5UULgvk2m8nk~2^#fH_nOb)b~CBlAvXP9)oZ;TFAfx}h3F7)uCP4q`O zJ3VUIo%tOA;&!(PJpAnR$c56Vk7r0xD5}+NnnklHSsPD)C&aWeezEk%=P;#uZ+-Zn z2i6rl$R=~;TVlSM2g3OrWCRStulhIKb-;MMsz-L0PAF`fJjHXz5Q^A|pRRUhue7y2 z-P2w%VnYrJLPA-Y5J~d&u9ZqngV<>A*R#_jG6E|H*A3Llnfs<}S8K_N^bs~=E;j8iS2?Qj&uhMU}_TV=Cv*R)q zlVHi8=Zjcnce1WZe5U8BD|ONRIBqrn2w*^e3>M2>LprreJi!uiUR&TP7MRr*_`3Nu z6!>kG*4Obh*71i2f7xxC82?7VtHX7I95w`V;cQQ36e?YMohXrNEA4R(QTWirglBw_ zCV(*|k^J|}Qw8Zgj666{C8`%-9}!2=e$bU?S81dAhTn+6LFjy4f0ynRC(?lctY*An?& zZiix>i`mq!%e}HMv&%A}Kp}LEHa|p#;&h@^UjDrs`egm=-i^GCh6bH(-54{pZX37L z+M33BzJU9z`Q)oGoxsk*Cr_Wq|K~yY^vQzf2pp3Mp6~tIT;wer-a3*8zQL>V}22z$MTlDJ57PStlD(hSS z2@`k~<@Mi`n!3H_G8K7KPATjo<64oh8tdj$->MS17bF~e&3P1tJZaLg&ott|%#`#%44busp zO1OmwF^7MCcYPSPN)yrUQe&<5?vpL5;OUbsoX96zxUzmptEjL*O1e%7fzxBwZt`%C zc*S;H7k7UdnA73_e!aTC)A{x4+ritL9q<05M|khhG3?U+VxBdHXYQpOnW%623Jd!FWV zL~e*ePMj^-)e*u$u=Fz7boXiZu79EXt{iJ+dfU50uB;NJkSp_DZ4Q#!Ty;jh4pX;z z-AMI122Z`I(Q&=)Ir>{p#}b8nu}lG}`1%FEt`EGyfXSL&Z(O-{b$3mYQR;-cfp_Qz zeuTyV$|l(#?jIf+SFwit5=(H`aEB+!$$HZ~EZXV3;MdK!>Z-OoP@QgPC=Tv?NBGS@ zDDuXRo&j2Q5hd{PB|je{C26|70qzh~LtrZZZKG=PAe#Yr?;f)gm!NqlDYEaFXD1*KO%ec?7s5n3>z$k?0aDGEZ06gs{M;UK59%l3mf zrjGz24is#Vj!jD-a(Ps$fUeP>;_g;**3rqytMB^zFUHSLhKM_^z>(iHK`e_w%?P^4 zo%`Q*x4YZj9r)u-ueb9S{?Y60G~a5NWfkN!I(#`eIU9{%LVY{i<81&IDJRudxO{1$ zb_YEWs;2Es+mNT2EF|n3;@k`z+B^%Q7{q=N2V(Bj*H^5fS#as$RpBsyGI`-Tc8~`2 zG~|^mxct}@_F$(4%Z@35_!TqO-K++>_1}~ER`tUGND0nG8bb=ev(qELV%`HCe#u+} za{!M4&fqf5!aG}$?A8&0JjlRvJmYvgf>^jaP_b~qL&LcB`-(imF)|NpBFL!O(wG)zWXMX%% z&4v8ynN@d?T({mBuXVzhw{6x_|F+cOhWeOdFfp|CFZWmTa6<-&4KFi=5 z66ct(lfQK}?x|CSq}4F{q&e?^D26(iYSV>`Asb?Qc*44Lxkb}!-Jrw8Lb$yvgx1T* zeFB8dV>en4169($J*{<-+7{UjkEt2Xr0K55ngpy{);!=X*TTF44*X4$xhU&^5*^d2 z%%Jd$UXXy;W6f&9fsf_?-06ZUJfL4H2RfHxQr65ku$Y_;(1IlOZu@qfI06@IXB-rM zmFp!TP>QR()$ZcdwOT0TH`NJ)m4&4+&OChXxK*uHXHs9L_775P{M4PIxx||opO>(C zi|+~s;42tJdQqh}RTyI^S%xxJI4jco0P~r25ahJkt}q)KOW4Zws-Kp0=K5s1P=Y^x zJ2|pLnWa>;k1@!a{pM2g)onP9%nT1z#cPAvrYji7=hyALBI=>qM|spqhgl!}|G)3g(*L=E5B}~pZp;7r=)wJx{_pYjx8Huz|9y^M;s2j5mcaxfHlyh^q`e2S zQ%Y&AwSDhW!A*C32hPkt6+U>ghyRbShs@8fm%eiImm8jp>;zmWk&5yh@;{;m6TpFL zV0&xuM;Hdnf=@)gntb!_GMDWir2zMu%a#;2F9Wk3>p;5lw#vN=Y4`5fUIn`s!HLsL zHVrZs%w^h7aMY99!mou&<3}C&T41Jh$lDZ04y52)5M09`62@1fbEP0IGzpV^<- z&i4I>vgb)>nWdO{Pm28ph7HR5q|a^vca760K#0NwSW{r>(kWXdGUFeMLVM{jKU)PF zNAd)V;A)WMfF4aZu;7TE2@Jx^VA(QdPMK`2_yQHBs42BM49L!87Am))8RH7na#0cy zmyKGUupQfCRU@(+pJ){!Tc#p{FmZ*YT$0b@AX-5Bc+aqLvLwB%3TUe&k3i_A995hr z$dg0nd3$73uHwB&kDp@QE=w9JaZ3QOx+MX*x}NTp=eO)k0a~#Z_4W;<(BxOkx9S99 zMdc4vOF#vl>{a__tt<9~0`Xf>XpEX!0@K}lUunuc6>EEYQI$0H#;O_pQMO5q0!uX@ z@LTjZXI?kEvt^ru%=TlRAbCv6CkqY@UL(x;t#iXJY&RmIy*zOt7i0d>}na?2Ff3*%2NMWx%rAjY_9Ch zMpm{W_p#~5WXx2OCWFN?zowW8GLbpX0KTnC$nf-2*ur|{b55CQ$yfvWx&qg-$ZG6H zy?0tGu%m_n;wJ)!6m@K*L|Rh3qX*_lU)gZGKb2AFkbL-*Dw@EApb^PE_1NJo*gl;fD z0w*ru5>t`0M@$fB=}C+!WMhHhlLa1cGeL13^8n*Rz+Q%+GVp~0XouD`ZU9Ay1k8Vz zN50NYO@=1SCoN$&NiQyJ!Y#*tIZtD*-uRx%p++kb<$}jkvi;ye31YM(Y_|1~Neg{< ztB25+ea3JYj&Wmf>ik_3trV6Y+92DQGzQ^^WG0$|U0%hzfHhT#J!vMj-LQcX-ilIH zZ?Gd(&<*h_v7^iW72;UPDFJO+l6at*WHDKy5#lNz%6#L^@*}&R3w|w`fDymS2MMfG z)@pY-{;NgJ7-PKP*UJ6(NmX{e1LY}qr{HmO7V|*lIzfn0@0y)o=UlkV!gMkZ!^%{s z7l@2Q|13e6kg!$$PLZ}I{lor-PWoXP{KT$76#_R&`A-PSGybq5N2~+^o9YcpNdAG1 z;$MAYHZzm_o{U~9Sion`9E_ohQ`pF|8M<7}Dp(49gM+B-f5|E|OTopuw!-1OB8RG) zyJ8tfd8^rNwpD33aSv*nDu-Z#8HR|3%f_Z)<=x61>g64LL26qs%pcTJH;89MGVtQ6 zov|RErCF5E7v`MFD)Q?kR#Bs3sjCu*mN_YwLDZg=){VAe7OvrzHjHM*12O~lNZno0 zV{hcp-m>*w+-Owy^bK7NL7%w#sQlc~m%*}?DWZ44715)`ZOJjUOA@iFqe|fA8bl|-ew9*5B78G0P7D7Li?e{Onl~VbE?Du>oug^heu9&qSWJn!yVE8jBDb;~{Jk zbsyE9YAHK3%k@S=$SUY^s;j`hy7cFoPnDIZ3#pbA;QLB7rEC*amv!~j4y-6tUQJXf zv2SkEmDbgqr1|)o=VL2I-_Qv`6LO27o|rwoMHO7vkYd)&HlXKE5gDTHE2-G5Or=XZ_Jy`kUb5oQa{TFrNPWHyd(Hda{r;H#Dl`~mKuOZrBLVKrBM z-Me@5E|1ne>a$4y*siOeSdbD^OPI0cXqJhBFa=s7|E=RK1jSnr`4YB{Xj71jjt?%@ zp(bOCV=>HpD3aIHPi!ttKejEJQjaTzSn49DKIoAZI=!Ed9R>~aHjr5#Ly9NV-$oVSSbif zP`aW1eJtk6{wx361^=#3-NtSCUmoq0?Y|Fpw!h%Nf0iH6k|t=zn$;Ks)KfOq(eAP4 zc6X<{-8lyp`8K@_IYoBM0>dI>4A?&`Sf8E_@6bkI;Z@;B72q48cbFPAlN4a}MY}nyI(rT`<;iXuNg=CQ<_~jdyx^2s;kX1%pF zi}LyEygNx3TM56)k$Rm2nhMXhNcC)dPmYJwN>d&yLB%?HW*dyusJa7-G+arD=_Z74 z52jN-$wjkQgzzAlPHgr+fFeyAO&=Nh@R&?CO8YCgEMPYi4~4ySdPc z=_Xm#D}~;PCJ{I})~-c2sJQh+cWDtsiK{w22^)IUoJKLef1_qt=-ECU^bcMRx{DB+ zdW)sev@GWPX)=vw#TaJKet&{h%+5_Hp+@N$7!uM-f|-mVi{H`G8XZCvX7_jGlFkl3h6fy~&%?=n5oo(VOvvXF<;0owO!M744{xVV-cB8|!~I zCI5Xtb^q&g^bcs`7XPmYJNF-y?*IE={J%ca?+@kFZ;i&VKJA;#>SkmOab$M$Zb{PS zH8RXhLbrO6n?$C;Rv2N<^lR>HZQVwrVHf(VS2F_md z^Zo34nWqR?T3E9{0|byUkVWM(%`R|3UZ>dw&qPP$*D;6Df~b%ECToT8WE3sgtu!|# zEm6U8$%D*{SyC2g7;Tg=zZC0%W!iqX(HKf|He9BxH7&U?{?=~98Y0e~5!4d8bE{SWNci9;_5=CH! z@#K|()=V(tMY)QqMfE!tMiXxyWasejDAC()mr~{ERz2JWJ~XVpES+0l zhwL^QzVC~9V`-L#>lXg*bV%n={+xQ>m{8{(EUT^o2S>j@u*(c?T4opRJdrZP({4Wd zTf^+e7C54B<>}U@FR*4~GGIK72J9kqvbzl83sA$XW-^$UO(1X$EKcu(m?ybF>3{LIY?UO4loKqM(4Q!2 zyz%)}0AX!HuSG`+N*#eO+|=W?4&7F3apSkUjm97T!1}Ka8;uX_du2raK%wFv8Xr3P zC;$1O@qziJ#D=XKGxSj9ztXv_jPm8q_081D4mqANv1w@a8vp;Xz4y7NA7nS z&Q<}gWzUl}8XM@SeufBTjI}gMS-Y4abnZZ`OY~N-Ffb*u7{U9;}(qjs)QBL6yAtsO=sbHIy z1BQcDo^}Yi4wH!$0HrM-5a(nv9~4*sWR{(S6E%GsK^`;C=xfMo2A;&%>54*Aar}Tm z@wH&S>Y=||fWhI#u*|p+(xFI!^fAKdlM_{>58Hg?taNhqo5`8utykoC^Ke^0dZ!@g z@Rv;8=2h~t=}ly&AllqVeQs~324;F4H4~IpW^)q;J-Evpmvp@;pOGWgo%Epc zBq-uU;)xJgzB=XrHOM|R8vTMQMnJtwGoYr3SnIV022v8Go=sW?f z5YQdTBH(Q(J>38Y550Q~k1pVz7eN%WAPnJ9cN+@wM#&;Ua;?4KfI$u-lmYy|`X0NS zM-cEXB&k925F|F+O!arfJXlH$NMQad+tt@M7!M|M=~{=*bnGw}%5jGI6%T5kU2wh> zY_$ZgcV~Os+uq)8SP>4e4^_;>0>8%@Fasov_=pzeoB}gSUH~bQw<<0OdCoO_IvT$8 z)Q5Jc&32caK}>dzD;5I(lP;B;93XTivzV(6?aI!DE8=L73I`1_4>I!7GX~m%8%HpI zI2f_xpV{DNaCl^4kOv6207gH#V^&G@+lqrT2ry1R$jBCq3}QK)0ly$eKH+2t^g)z} z83k6xF7qpR4Dtx59oZbelb2B{B-gH{yH5DPtH#F2jjud&--WshcwVucs$ZE-Hyz&SZe3_ zX!MGy0OM`isY$977(tdr?*Rrx)?v6#?-q63DBN0QY+U0SYQc%3)r?B=>>A_7B`3TK zl?V>mQQ5=$f3XV^w1v@Q%;g64gC3H_(IS#SS*t{2VK9-dvI!TYm?ZJZMHUnt!ku03 z*Q)$kSr*z%nd&8O91Azgl1~C+Pyh+s~QU>xlj0tIMoX!OB zSkjTMVJSldC$_Q+apZ*Q+*^sa6BsM5JPC}@i% zP}d63S`mI*InX(nzdry;AvfqrmI~2PGAol>X8c6lQY~uI?P^IO@l8d~-Z&V%9t_5Z zqwx>@(cpFem+{%@(Vol}p|QGpJAeN6FWanA7M)D?y4^1OwZU#tDwOs0*sWj|MCdch zVu%GZV*}Cg8Duop6yZfD%A1DlFIDcSfr5&+)Ds_3qON%9@4Lm=12D)SP>87y!Pp${F@ zpyC-gB6Dh^VH#60h5>5Kpu;kPw$N{N|4kepzWnfK#s3%4*C`rwGab9eNEJP@tlMC*YkiO|vc=fM_8_NkkZti1S6X zy+=1agu*1?AxumFP!pLr&5W_c?r3@Sm8^G9CkSq`4@JTZ_;-<}yBy>%-QLqFi9cH0 z4J?YOu7Bvb*^!V>Vjzv=+}9rt2d5)IaR=}7VGosHKro-2@=2P7Ps&~PBqGhzr_`;! zxLzzcT*HPRImSDo=xIWBLL@$#+u%xb%#Et3uj+y6L1`B!WKjGIB;FGCK(UlMaLb$w zB`_M81oe{w@eIbqSV#m96ny7_lvt__cZ+qVU?C~Rtpo=fjhI7=P#AU{PeAWq-xmef zR@Wbej4*^+VIrQt?C%?$k|9avTmXx_VesWJ9~ks~4S_o{@aQgektgQU?lwMa5qq^_90a@T0DS7ndTj4sj;v=T2kU((8% zh`hQP`s7))oxYOvk@jBBfyzm2jCl}G$w*Wl1}KnWp-_D;*G%@~nDbDOR!cW-t?{!S zv}ANZQxDiJ_IB7+x*NOpE)^kxM@P(~xntfXzc_Kpv`BjdHp6)%R zGbNRYY%!W&3MCzcI|iVsk;*!l8<$$HM}G>$Bmha5Sm$Et3?FN2=}r~q7)Y+Zyo>xE z&XZ{hp^#_V`EYK@hP)wXAXBNG9Q)-+yGsz*Gc<;Ze%d2?kCKap=&~aoyeFEnBBhyf zv?NtJWr;y`w{9Ejd)6!38o-0PK1fw+*%;8%D(J5TD*U9&yPj4uOQ;bl7k5=Z-bfLi zHOy8;=|&!GZx_0+$J+&cFzkvwmS7xDQQtno+@$Y$mPKIa!*q5a8XB{G`K*VY-Njc@ zP3roJbZNz8WAx`7<~d-~jEgy`kY(lX^2k_7!mWFy3=W3>3r;)c3X=DQUc=JWZ?6k7 zMP#eN+rBW?;Gblz!GGE-BpCuEbW)LHlV@P~3YycCd%E|;Ch0ankGdNRWc8wOU$)Y6 z67zO3MP;<_v$Mm4A|ARGrMmwbLwlqDHL#_^TC=)?e{IvmHK}JR*e@zD;Ap|7SK&x((ppJTVF0lpx zMu0SeO2?Q1`CQPU^Vl+838qp=l0sZtZb6lTN5$WgG)EQDM+w@X!z1MqOK>qj1txs+ z)RoOMcn#mk*_D_z# zKRVopjEYSxHTe0JDr6eh;O|$1)0c;ouI^xPd`J(a#qI34|5N|)sQ=y3p!s&!;!LbL zfc#bvT`j{JUE^xMAztk*UHU(O0zR7PNj2vi{cCjclXQi5FnVW^t*)#ju_sj-FKXvh zW9I(vvjHZZY#yJC#^0Zu9pliy?;jow4gh5k6bXl*Sb)dScnn}-K?FzREb=h7Ru!G#TD9XNvd(cdk<3LV# z;3Su(Vi;byrlHgkV+zcVtMp7(Q+@WKI_V#*#&;!xwYDisEUr7!7Q-0br4RiLRONK= za&U0iM~zs2AO2&{wF~{L8Hfw?HX7&YI9$LJv`LTA;~oA)r!szSywYsk>P6BJ~ zpCe&nyX-rHx$>9@rW~`>rn8ESYE*G5QMUG&#>N3@w7{=`zX}W(_zR;VQDoIXDOgBB zO28iy9s-LK!~+&Fz!~6g8&W|T3K07M{!owx*w#v+o-hOsAYmE<{B1KxvD}2z7Mz1_ z1&s^(hJs70%Ld5|{P#VXBWFgOi1Gc|;Re-m>BfA1csz1g94eH@G&$4>Hj$`&)*`sV zLMlZ6;Yd_Tn`2{E>Ea>yG$`DBsMt+9X`5<s^0hw%#&-Hsvnt{%25*Ku3^GvZX3n{<%1=SEO}K_K&Dl%8>p5` zN3PX|Tox%r+?FcVkw^jnD^+4Ev<9vw6V5}5I5WVsWyCz}MUOq&e&8{UdfvABpk2zG zt&@IS@GQc-QsADN&ta3FuX5R;DVN#U3(ac^(O^)Yq(^jwJ+%d&I`&{ehDyMCB|6Y{ zt%X)oeFyGM5_z)_Uy1z$H5+95Dzl*=)L{k@FeOG9A!G>_SOrwh6LkOL0ZSCL>&(Bw zXFZ640NN_M=wX{Z)>NO4DbJ8+Zt?ZnSKyN~DcYn7YNu*yU1Q8iqUMfBF!|u({NvH; zgdE)xrK}4g5ZeAwj!sw*Uk2BRwka+naLz=R?ag|wY6cwp6szPbT1#3SO0`z(0|JWG zEZIL>&GF?|^RxAT(#!V~YXEQY|9SY}ei{Ge;m*VDFZ#dF@w5GZ%68Y=6L*-}1!cwE zn0%wZ%I9eo{Y|>_PUf62gNxk18EFJdnKtVDa6lb09_~LMyu^GPNPIea9I{cFz65T!Qd2n@pFgzp{QU-`pwJkpag{st*aA-U(9Bo z6Kb^T-cswM>nOrcBUk6cI+@_RtM13Q<3l z3xd#eIy8_BS2z_wg;jZASehdbFsAdA2BD0_tfkFx(p3E5@%C-TViJW18huE{dKQ+W zmaqK}v=f@GPN+`d>nNY=y;yMA_5_=F%*Yp)22GeeBr3@Gs5A;ECF86alPV~b&@jNC z93Jem!viLfOi3Re2^AA9TaX|Qw#yDix1*1rEJl+EUFe&J+=`7GXo#V9)z0bZXj?PK4S3T%m z_M9SxjTm5GIoe!C;f&|I(moQWvyxk4F&&D=sG}hVZB*(Is}()ub|MU#e1rP2mS0E9 z3_+JXEbV4=WT*YO3`(e9Z1C`sQQP)I1C_7v+fWhQ4&mk%xvNT3F*>?0FhJToMPd$U z-#MX!yvG@ebn5x91;&F(d<;VZvctwp#{lhRa#NT_b&AoJ`2o;HiF$_aj-418{L0mU z2=lN-;3J$CsJ?ZlAbJVJ;ZAk{W_?~{fmfg_=#Zdx_b$NqqdX`HxDrjlWkN_-1f5L* zB9zkj7W=v@tgKga=nVUMY0DRSuAB82QRCx&7XM%1u6@!rZsY%Vwk!7khmXJb|9_UB zq)?@psGgB0#R&wZJOMgNb6GmcYl##UDA=Y7N)&6+6{l!hyKB|wM1xrOZh@9^_Oqz8 zs$T8YAWC_4092@5Lf%BqfB`$zlaAOQ3p-`e$eVJEfw$SG-K%%M_gr}y9S(V3+0V&y zhhLN*4<&#=T|9z(<93(wT)vE6y~L@f$CWD5VZ-*E4hNN|XjJiB)<@56p7Y9)^0uzo?WbI<;#@t&itLi7W~KU7 z(LLRdQvdc(j*kcXqw(orbar|?e$|IFk73C+u8-%YutfLDMKySlI??5i?5FZ80Nb ze|a_V>XO)a)eBC~sL|*0v6oLO$;ceL&Yd8-`Gx8JxBXfCe?e3}XZ-KO?MFM6_}`r` z{=c8)w^9G^=k{A;DsA?*BkYM zO0lhg*{COxboS}F(4a7)SgJ-UO3(UmGDmb=nS0RVFvoUN#A?-tYQ1)aW*MPlQaubo z9S|l|1ZuGuP{20?Yi;T_aW^VzuTJA9yKX5BrPaBKAxzQ*jAEOllNNFSW+*RQj?9<@hY6u}h~W3bIQB=fu@(_t5&Uu=OaKMFQ%nh&*Fm z)F>cU2xEe+^637&N|3=(F~IWxRe}=fakskm=+QQj{=6zC@02fjkQ4r}9A^+q0|>~A zpo#FlVAN%e7RvmQb$0sMY@m{7U>kh{~T?|G%^SV5h48|C0aV^ZY)$|Ie!+pDUkP z(%@?-cECm^A06qwEzzu{EFg9K^=>f^-4{6HY{{^eEu!;H$1P%On@U&4glBw_Qo3-S zVc>*xiok&LRD}^%v853g0Q`*#4{n84UA4)-M$q`@Sb$)WPTZlyEHc6 z^gTx`?2U3pt)k7C0YucWA@kELnnmCc^TWyhQUCSWj4;Fn_^v-30Hz@1P>h8gWGTT- z&Q6aC{{eeY4v-W;Nd?<^97IV6aa1REUvSq%0y)VzO_!KRy``X4B#5d&!N&lVDTA** zGr0}LnC%m|xl9eAQ1fU;1edV^b!Z+Y1iH!*E+J^}n}+tfF%LnnaJ|O)D^PNkyKfdu z;pC91Od|py1hWA@gIkhV^$LnJW|=3(Pel1`aqv|Iaw|%fAPC=-wzZBtuyI-7B3(6)5)+aP!)e;q3H?Y<9|7VKekfJ$G9H#+MZu8dnN;(#Byvba{~ZG8t)c-I=LL{3wTD!f?%CSyX# z6fhhQZgmkX`Qwc=^;g`z0{2Hq_$2QPfwF+h)T=a(Cf5j{i+4| zHSqbbWjF^w!Kx-UrWr6Y73ctWT%-*t1d1dvrZ1mPm$31+DD6h$jzDoHM-z_RGd4|= zd23;M3gSDdIy)|8=GP`v*fjP7q+g)86F9%vT+2`;RBqm4A?d50ku$f((Q;i`-+rMM zdp>%3WUwdD0U-eeuv0BsR*?U3Jv6+$ob^CJ8pjxJCCK#z7d(KsQ9Fd9vL8(5yaUUX zr7_^dbVQzJT#~j^U_COP$(@AnGx^MiJVfBm7nu76kfu}%MYah~#iQUx0hq=-rTs$M z7s@kXZ4O9!QR|cX!(gOGhO}0T&<5lRX?A{Fao~eqVYl&#POSmBGVn3nWH2|YC1l2t zP3W-6c_av6*6baCsoRUWcEkrcTt&!1MVQDkmV;_up^aYQeg|%W(1d0UTMDdZ&%Bn} zUL_|(kxm46l4KxEW)JGefJs$bUOn3z@cB#Z=D ze2;_0EkxZgn#)e0%tq9?+|EUml(k2BkUPS5htiK$Iy2+mR%~w|*erU_6HI(_P0|jz zeFse5EVzL+3nz8i7-=3Q7lt-2O{e?l?R%?ZFmwtN=~8s1Z!eS7ieD4x84t2J;u$S; zA*0|~2wg#&mGnvqh^CTcNpn7Y2{Vq`YqbZ+sG=aapl#TfI>i(5`qD?(=Gk2OL`o5* zqVY5d3TW1lcBNrqYmkIU&$&6CV&b(yjKmGhMN1(pyRse0nxCin93v2;P^Ls7BL!uT z!R#;_iBgd*iwIGfd>F*X*uei5fLs4!KTH2tsB1o_{r|zkivRD!hdYnH=>I;)Pg|cX zH&rCY=UI9wRM0dy0TaW799|KvhY3ZAKMRsD=EA=R8i6nL_22HK3J+JZ_1o zVACimY@`sxtda9A1fRc8qtNyxkoh3auMK!)V!HAmzR-xaQZMC>t*PZsRN5a>KM}r% z%(%#Wm93G|cFD((v=M^48Dzi&mU|jqfnw)^FN;g2$Q)G5ag#0#HDT0JZ)?jZ?`q(3 zotiMuPAa7IVSpzvNBnh^bW))M!64y!mz_+fFnv;3?!gs^*Gmw~05d{eBLi=W`X(4T zYXSS57I}`^Hp{+I?WQJJsbXc)BiI&;D2}6w2u~T13c+0^NpfIq^cf~bViBwd;0?%3 zK4p;OS)85+F%>QZN%-qok4fkr{L_G#1-2|!c`>l-1p|tynE)L5Jk9bU{-w-@CZ6a_ z?C7DHC4_x2GTAfamcUf6)QV*}Zy96&ngrMwq<;i73WY+^Pp)Np zS{cr8&LRE^HUT18Kogq)6xD3X>A=R%(-5PYGli+Rq*J4EIBzDWGi#ZijGjuy%O-%} z9sFCbw5A3xR-M(SmhM)kSW*g8RG-`xFs=eYUb!xyzJZhrY#^#Wa{YU}MGC6`=fc6(=Qo@&#ov{>S=>1sEZm=oah>^%Q3XRZwfU{df zMWQ9eMQdb7voi8Rv~^!jh1k11aylU{IE>u@NtbzALm_jqqSB74)YwzY9P_PEQJLN8 zoNJ{TJx^|dBVjS&K4>`P)BgiQ88 zBlPuz`K+}{qAO)LG_tH4x81LkfM#PK zG;1ZFJXM0jVbW1)cO|E23=mmF66jlICf>G@iZ`7yE0M30d>~d}CG4pXpoC*um5`~y z_!hy{Ajtu9XV+aoaGq4L_=?Z8C?_HK{*D=y#~s3sqrV{vUd8nrQ9jE%j$bV~LeEYM z98`&djw~#CXb^9UNfJN}N}_lS`B$XfmNJlH(k2a(V1h89pj%`_&H@Qg7bGl}c~1$# z2qpk>My?WU338S?H+@}9SACMEuLaR}N@}iC9f8d$As6)XYv#_|R8Z@3R`i;LO~s9C z(mu&+>`MKSguGD#lS*}t7@RmwhM|Wtue)0-l4dI+44UZTdaae9(PVmutM@kgF3ZzZ zXDm9Y#e``eaNl1gZ!8<8=dPKu=)3~0%=x6M{y^{Zg0#%=T)Q-Bh7U%O#J*>egWsEf zM`03!H(cOiMI}}Z@npMvFpCYMf;vMS|en#%E_{4adW+m)((*4znrI_grzry!-M2w5Vf8( zFquSzjbz@5dW{R|ywPA*pC$X8V6k|GP90+zp_d_9y(hI0L&vIcx8wDliAJXN+M z$Qw^!O-ZmItMly{e=1?X_NG=IYIV1Rq0up3ezC6ocl;^+&(Y!j;CMLrhqQ5v{^$Oq zopS!)oyQM$zUY5G#}8e6APqY~<_Xr%^DH`F0U_udrwKgf*(3^L_OQFn4kKCHL{}1J8(!1Bbq)?^Y_*gYPE}*0ai#}3O~H(Uj6Ba4 z0()nS6Dx-ipszT%$pAl>rOPY=i3tpF;9R)O()SQe8IV&5nXMX3&8jD}41}13gGE!O z;O8!Zse*9oF13Q0>OhL8K{ph_JnCr{h-c>^`nQbGgo&Y4ci_9Wnt{5GxF2d7u(RQc zPcx7`uhSLGp9*~I!eH%%)WZ#~v5A!^Eyfg$VY<9T;1sedNQxK99RS(r!TAxzn{vIt z&0MpX&w`jHo?%aOKA9(JoX(<25VHjj#46)L$!)VBMksmdREhG3bdd?uD48`_;ig|q z0cT5|Eh0d5Y8F-e9K4JSF$i>%wJHRe;81C18_Ew!y3oiaZ>NE@8R= zQ*fF|Y$-L{n4%kdk`wP9fJdcd>tK2L74T3fc4jP&77>|4)zn@F87Re+O$lAL$Q3|! zQ%x>zQ&{VO)C$yHY+FVzCmf`S0HkHLA3#P$b_q5HIQS~_xknQXTM-D0&_-$+Zv>eW zQG+nKM&53bX0ZBHmXx#L5Is*X8w`6@-c#L1<9o>yq9Q~M2gFqmbP>IVGT=`nNtzHv v>ZaLjOBNSf=hvNNvh}dLy@gu^xWg|D(wE Date: Tue, 20 Oct 2026 17:21:19 +0700 Subject: [PATCH 2/7] feat(server): verified signed-in users through dewee identity session exchange A host passes identity.authenticate(req). On /session the handler exchanges the signed-in user for a Dewee identity session (one agent, chat only, short-lived) and embeds it in the visitor token (ids, ida), with exp capped at the session expiry. /chat and /history then connect with identity_session and no user_id, so the gateway derives the user; a token stays bound to its user whatever cookie a later request carries. Fail closed: an exchange failure issues no session, a null or throwing authenticate falls back to anonymous only when identity is not required, and identity cannot be combined with client credentials. Gateway identity errors are forwarded as {error:{code, remediation}} (JSON before streaming, SSE during a run); the widget shows localized copy and re-requests /session after LINK_EXPIRED or LINK_REVOKED. POST /link starts a web-first link and the widget shows the proof code with the chat accounts to send it to. Anonymous mode is unchanged when the identity option is absent. Co-Authored-By: Claude Opus 5.5 --- packages/dewee-webchat/src/index.ts | 2 +- packages/dewee-webchat/src/protocol.ts | 52 ++- .../src/server/gateway-client.ts | 49 ++- packages/dewee-webchat/src/server/handler.ts | 299 ++++++++++++- packages/dewee-webchat/src/server/index.ts | 28 +- packages/dewee-webchat/src/server/security.ts | 24 +- .../dewee-webchat/src/widget/api-client.ts | 92 +++- packages/dewee-webchat/src/widget/element.ts | 88 +++- packages/dewee-webchat/src/widget/i18n.ts | 34 ++ packages/dewee-webchat/test/identity.test.ts | 412 ++++++++++++++++++ 10 files changed, 1031 insertions(+), 49 deletions(-) create mode 100644 packages/dewee-webchat/test/identity.test.ts diff --git a/packages/dewee-webchat/src/index.ts b/packages/dewee-webchat/src/index.ts index 8a944bc..bdaefd7 100644 --- a/packages/dewee-webchat/src/index.ts +++ b/packages/dewee-webchat/src/index.ts @@ -3,7 +3,7 @@ import { DeweeChatElement, type DeweeChatConfig } from "./widget/element"; export { DeweeChatElement, type DeweeChatConfig }; export { ApiClient, ApiError, type ApiClientOptions } from "./widget/api-client"; export { renderMarkdown } from "./widget/markdown"; -export type { ActionManifestEntry, ActionSensitivity, AgentInfo, ChatRequestBody, ChatStreamEvent, HistoryMessage, PublicConfig, SessionResponse, ToolCallView } from "./protocol"; +export type { ActionManifestEntry, ActionSensitivity, AgentInfo, ChatRequestBody, ChatStreamEvent, HistoryMessage, IdentityErrorCode, LinkResponse, LinkTarget, PublicConfig, Remediation, SessionResponse, ToolCallView } from "./protocol"; export type { ActionCallStatus, ActionConfirmHook, PageAction, PageActionContext, PageActionInfo } from "./actions/action-runner"; export type { ActionResult, ActionResultStatus } from "./actions/directives"; export type { JsonSchema } from "./actions/json-schema-subset"; diff --git a/packages/dewee-webchat/src/protocol.ts b/packages/dewee-webchat/src/protocol.ts index 1d5c20b..f471693 100644 --- a/packages/dewee-webchat/src/protocol.ts +++ b/packages/dewee-webchat/src/protocol.ts @@ -9,7 +9,35 @@ export type ChatStreamEvent = | { type: "tool_call"; id: string; name: string; arguments?: unknown } | { type: "tool_result"; id: string; name: string; isError: boolean; result?: string } | { type: "done"; content: string; usage?: Record } - | { type: "error"; message: string; code?: string }; + | { type: "error"; message: string; code?: string; remediation?: Remediation }; + +/** The seven typed identity errors of the Dewee gateway, forwarded as `error.code` for signed-in users. */ +export const IDENTITY_ERROR_CODES = [ + "IDENTITY_REQUIRED", + "IDENTITY_LINK_REQUIRED", + "IDENTITY_CONFLICT", + "LINK_EXPIRED", + "LINK_REVOKED", + "PERMISSION_DENIED", + "SOURCE_UNAVAILABLE", +] as const; + +export type IdentityErrorCode = (typeof IDENTITY_ERROR_CODES)[number]; + +export function isIdentityErrorCode(code: unknown): code is IdentityErrorCode { + return typeof code === "string" && (IDENTITY_ERROR_CODES as readonly string[]).includes(code); +} + +/** What to do about an identity error: `action` (e.g. `reauthenticate`, `retry_later`) and who acts (`end_user`, `operator`, `integrator`). */ +export interface Remediation { + action: string; + audience: string; +} + +/** Error body of every JSON route. `remediation` is present for identity errors only. */ +export interface ErrorBody { + error: { message: string; code: string; remediation?: Remediation }; +} export interface AgentInfo { id: string; @@ -26,6 +54,8 @@ export interface PublicConfig { turnstileSiteKey?: string; maxMessageLength: number; version: string; + /** Present (true) when the backend signs users in through Dewee: the widget then asks for a fresh session on every page load. */ + identity?: boolean; } /** Response of `POST {basePath}/session`. */ @@ -33,6 +63,26 @@ export interface SessionResponse { visitorId: string; token: string; expiresAt: number; + /** Present (true) when the session carries a verified Dewee identity. Such sessions are short-lived and never persisted. */ + verified?: boolean; +} + +/** A chat account the proof code of a web-first link can be sent to. */ +export interface LinkTarget { + /** e.g. `telegram`, `discord`, `zalo`. */ + channelType: string; + channelInstanceId: string; + name: string; + displayName?: string; +} + +/** Response of `POST {basePath}/link`. */ +export interface LinkResponse { + /** Show it exactly as given; the user sends it in a direct chat from the account to link. */ + proofCode: string; + /** Epoch ms. */ + expiresAt: number; + deepLinks: LinkTarget[]; } export interface ToolCallView { diff --git a/packages/dewee-webchat/src/server/gateway-client.ts b/packages/dewee-webchat/src/server/gateway-client.ts index 6e22c64..45fee1f 100644 --- a/packages/dewee-webchat/src/server/gateway-client.ts +++ b/packages/dewee-webchat/src/server/gateway-client.ts @@ -1,7 +1,7 @@ // Minimal Dewee gateway WebSocket RPC client (protocol v3). // Runs on Cloudflare Workers (fetch upgrade) and on any runtime with a global WebSocket // (Node >= 22, Bun, Deno, Workers with the WebSocket constructor). -import type { AgentInfo, ChatStreamEvent, HistoryMessage, ToolCallView } from "../protocol"; +import type { AgentInfo, ChatStreamEvent, HistoryMessage, Remediation, ToolCallView } from "../protocol"; import { stripPageBlock } from "../actions/page-block"; export interface GatewayCredentials { @@ -13,12 +13,18 @@ export interface GatewayCredentials { tenantId?: string; } +/** + * Who a connection acts as: a gateway user id chosen by this server (anonymous visitors), or a Dewee + * identity session, in which case the gateway derives the user and no `user_id` is sent. + */ +export type GatewayActor = string | { identitySession: string }; + interface Frame { type: "res" | "event"; id?: string; ok?: boolean; payload?: any; - error?: { code?: string; message?: string }; + error?: { code?: string; message?: string; details?: { remediation?: unknown } }; event?: string; } @@ -29,11 +35,17 @@ type SocketLike = { }; export class GatewayError extends Error { - constructor(message: string, public code?: string) { + constructor(message: string, public code?: string, public remediation?: Remediation) { super(message); } } +function parseRemediation(raw: unknown): Remediation | undefined { + if (raw === null || typeof raw !== "object") return undefined; + const { action, audience } = raw as Record; + return typeof action === "string" && typeof audience === "string" ? { action, audience } : undefined; +} + function wsUrl(base: string): string { const u = new URL(base); u.protocol = u.protocol === "http:" ? "ws:" : "wss:"; @@ -75,9 +87,10 @@ export class GatewayConnection { ws.addEventListener("error", () => this.fail(new GatewayError("Gateway connection error", "socket_error"))); } - static async connect(creds: GatewayCredentials, userId: string, timeoutMs = 15000): Promise { + static async connect(creds: GatewayCredentials, actor: GatewayActor, timeoutMs = 15000): Promise { const conn = new GatewayConnection(await openSocket(creds.url, timeoutMs)); - const params: Record = { token: creds.token, user_id: userId }; + const params: Record = + typeof actor === "string" ? { token: creds.token, user_id: actor } : { token: creds.token, identity_session: actor.identitySession }; if (creds.tenantId) params.tenant_id = creds.tenantId; try { await conn.call("connect", params, timeoutMs); @@ -121,7 +134,7 @@ export class GatewayConnection { const p = this.pending.get(frame.id)!; this.pending.delete(frame.id); if (frame.ok) p.resolve(frame.payload); - else p.reject(new GatewayError(frame.error?.message || "Gateway request failed", frame.error?.code)); + else p.reject(new GatewayError(frame.error?.message || "Gateway request failed", frame.error?.code, parseRemediation(frame.error?.details?.remediation))); } else if (frame.type === "event" && frame.event) { for (const cb of this.listeners) cb(frame.event, frame.payload); } @@ -140,7 +153,12 @@ export function buildSessionKey(agent: string, conversationId: string): string { export interface StreamChatOptions { creds: GatewayCredentials; - userId: string; + /** Gateway user id (anonymous visitors). Ignored when `identitySession` or `connection` is set. */ + userId?: string; + /** Dewee identity session: the gateway derives the user; no `user_id` is sent. */ + identitySession?: string; + /** An already connected connection to use (and close) instead of opening one. */ + connection?: GatewayConnection; agent: string; sessionKey: string; message: string; @@ -148,9 +166,15 @@ export interface StreamChatOptions { timeoutMs?: number; } +function actorOf(opts: { userId?: string; identitySession?: string }): GatewayActor { + if (opts.identitySession) return { identitySession: opts.identitySession }; + if (opts.userId) return opts.userId; + throw new GatewayError("A user id or an identity session is required", "invalid_actor"); +} + /** Runs one chat turn and yields normalized stream events until the run ends. */ export async function* streamChat(opts: StreamChatOptions): AsyncGenerator { - const conn = await GatewayConnection.connect(opts.creds, opts.userId); + const conn = opts.connection ?? (await GatewayConnection.connect(opts.creds, actorOf(opts))); const queue: ChatStreamEvent[] = []; let wake: (() => void) | null = null; let finished = false; @@ -182,7 +206,10 @@ export async function* streamChat(opts: StreamChatOptions): AsyncGenerator { push({ type: "done", content: res?.content ?? "", usage: res?.usage }); end(); }) - .catch((err: GatewayError) => { if (!finished) push({ type: "error", message: err.message, code: err.code }); end(); }); + .catch((err: GatewayError) => { + if (!finished) push(err.remediation ? { type: "error", message: err.message, code: err.code, remediation: err.remediation } : { type: "error", message: err.message, code: err.code }); + end(); + }); try { while (true) { @@ -205,8 +232,8 @@ function clip(v: unknown, max = 4000): string | undefined { } /** Fetches a session transcript and folds tool calls/results into assistant turns. */ -export async function fetchHistory(creds: GatewayCredentials, userId: string, agent: string, sessionKey: string): Promise { - const conn = await GatewayConnection.connect(creds, userId); +export async function fetchHistory(creds: GatewayCredentials, actor: GatewayActor, agent: string, sessionKey: string): Promise { + const conn = await GatewayConnection.connect(creds, actor); try { const res = await conn.call<{ messages?: any[] }>("chat.history", { agentId: agent, sessionKey }, 15000); return foldHistory(res?.messages ?? []); diff --git a/packages/dewee-webchat/src/server/handler.ts b/packages/dewee-webchat/src/server/handler.ts index 26f1a54..1d19a3b 100644 --- a/packages/dewee-webchat/src/server/handler.ts +++ b/packages/dewee-webchat/src/server/handler.ts @@ -1,25 +1,96 @@ // Framework-agnostic backend: (Request) => Promise. // Routes are matched by the last path segment, so mount it under any prefix -// (e.g. /api/dewee/[...path]): GET config | POST session | POST chat | GET history | OPTIONS. +// (e.g. /api/dewee/[...path]): GET config | POST session | POST chat | GET history | POST link (identity) | OPTIONS. import { HEADER_GATEWAY_TOKEN, HEADER_GATEWAY_URL, VERSION, + isIdentityErrorCode, type AgentInfo, type ChatStreamEvent, + type IdentityErrorCode, + type LinkResponse, type PublicConfig, + type Remediation, + type SessionResponse, } from "../protocol"; +// Type-only: erased at build time. The SDK itself is loaded lazily, in identity mode only. +import type { AssertionSigner } from "@dewee/identity"; import { ActionInputError, buildPageBlock, hashManifest, normalizeManifest, serializePageContext } from "../actions/page-block"; -import { buildSessionKey, fetchHistory, listAgents, streamChat, type GatewayCredentials, type GatewayError } from "./gateway-client"; import { + GatewayConnection, + GatewayError, + buildSessionKey, + fetchHistory, + listAgents, + streamChat, + type GatewayActor, + type GatewayCredentials, +} from "./gateway-client"; +import { + deriveVisitorId, memoryRateLimiter, randomId, signVisitorToken, verifyTurnstile, verifyVisitorToken, type RateLimiter, + type VisitorClaims, } from "./security"; +/** A signed-in user of the host app, as `identity.authenticate` reports it. */ +export interface AuthenticatedUser { + /** Your stable, opaque user id. Never an email address (an id containing "@" is refused). */ + subject: string; + /** + * Reserved. The Dewee session exchange carries no scopes, so this value is not sent anywhere. + * Report scope changes with `lifecycle({ event: "scopes_changed" })` from `@dewee/identity`. + */ + scopes?: string[]; +} + +/** A private JWK whose public half is published in the application's `jwks_url`. */ +export interface IdentityPrivateJwk { + kty: string; + kid: string; + d: string; + alg?: string; + [key: string]: unknown; +} + +/** + * Verified signed-in users through a Dewee identity session exchange. The host passes one + * server-side callback; the handler exchanges the user for a limited Dewee session (one agent, + * chat only, short-lived) and the gateway derives the user from it. + */ +export interface DeweeIdentityOptions { + /** Reads your own login (cookie, header…). Return null for a visitor who is not signed in. */ + authenticate(req: Request): Promise; + /** Dewee application id (`dewee identity setup` prints it). */ + appId: string; + /** The issuer configured on the application, exactly. */ + issuer: string; + /** Optional check: must equal `dewee:`, the audience Dewee issued the application. */ + audience?: string; + /** Assertion secret of an `hmac` application. Exactly one of this or `privateKeyJwk`. */ + assertionSecret?: string; + /** Private signing key of a `jwks` application (RS256, ES256 or EdDSA, with `kid`). */ + privateKeyJwk?: IdentityPrivateJwk; + /** Algorithm of `privateKeyJwk`; defaults to its `alg`. */ + algorithm?: "RS256" | "ES256" | "EdDSA"; + /** The agent identity sessions are bound to. Default: `defaultAgent`, else the first of `agents`. */ + agent?: string; + /** + * Refuse visitors who are not signed in (no anonymous fallback). Default false: a visitor for whom + * `authenticate` returns null, or throws, chats anonymously exactly as without this option. + */ + required?: boolean; + /** Identity session lifetime, 60 to 43200 seconds. Default: the gateway default (3600). */ + sessionTtlSeconds?: number; + /** Allow plain http to a gateway that is not loopback (private networks only). */ + allowInsecureHttp?: boolean; +} + export interface DeweeHandlerOptions { /** Gateway runtime origin. Required unless every request brings its own credentials. */ gatewayUrl?: string; @@ -52,12 +123,66 @@ export interface DeweeHandlerOptions { timeoutMs?: number; /** Observe traffic (logging/analytics). Errors thrown here are ignored. */ onChat?: (info: { visitorId: string; agent: string; message: string; reply?: string; error?: string }) => void; + /** + * Verified signed-in users through Dewee (needs the `@dewee/identity` package). Absent: every + * visitor is anonymous, exactly as before. Cannot be combined with `allowClientCredentials`. + */ + identity?: DeweeIdentityOptions; } type Handler = (request: Request) => Promise; const ID_RE = /^[A-Za-z0-9_-]{6,64}$/; const AGENT_RE = /^[A-Za-z0-9_.:-]{1,64}$/; +const IDEMPOTENCY_KEY_RE = /^[\x21-\x7E]{1,255}$/; +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +/** HTTP status and fallback text of each identity error. The widget shows its own localized copy. */ +const IDENTITY_ERRORS: Record = { + IDENTITY_REQUIRED: { status: 401, message: "Sign in to continue.", action: "reauthenticate" }, + LINK_EXPIRED: { status: 401, message: "Your session expired.", action: "reauthenticate" }, + LINK_REVOKED: { status: 401, message: "Your access was revoked.", action: "reauthenticate" }, + IDENTITY_LINK_REQUIRED: { status: 403, message: "Link your account to continue.", action: "send_connect_link" }, + PERMISSION_DENIED: { status: 403, message: "You are not allowed to do this.", action: "contact_admin" }, + IDENTITY_CONFLICT: { status: 409, message: "This account is linked to another user.", action: "contact_admin" }, + SOURCE_UNAVAILABLE: { status: 503, message: "Your sign-in could not be checked. Try again later.", action: "retry_later" }, +}; + +type IdentitySdk = typeof import("@dewee/identity"); +let identitySdk: Promise | undefined; + +/** Loads `@dewee/identity` on first use, so anonymous-only deployments never need it installed. */ +function loadIdentitySdk(): Promise { + return (identitySdk ??= import("@dewee/identity").catch((e: unknown) => { + identitySdk = undefined; + throw new Error(`identity mode needs the @dewee/identity package (${(e as Error)?.message ?? "not installed"})`); + })); +} + +/** Validates the identity options once, at startup, and returns the agent sessions are bound to. */ +function checkIdentityOptions(o: DeweeHandlerOptions, staticAgents: AgentInfo[]): string { + const idn = o.identity!; + const refuse = (why: string): never => { + throw new Error(`[dewee-webchat] identity: ${why}`); + }; + if (o.allowClientCredentials) refuse("cannot be combined with allowClientCredentials (a visitor-chosen gateway must never receive a user's session)"); + if (!o.gatewayUrl || !o.token) refuse("needs the server's own gatewayUrl and token"); + if (typeof idn.authenticate !== "function") refuse("authenticate(req) is required"); + if (typeof idn.appId !== "string" || !UUID_RE.test(idn.appId)) refuse("appId must be the Dewee application id (a UUID)"); + if (typeof idn.issuer !== "string" || idn.issuer.trim() === "") refuse("issuer is required"); + if (idn.audience !== undefined && String(idn.audience).toLowerCase() !== `dewee:${idn.appId.toLowerCase()}`) refuse("audience must be dewee:"); + const hasSecret = idn.assertionSecret !== undefined && idn.assertionSecret !== ""; + const hasJwk = idn.privateKeyJwk !== undefined; + if (hasSecret === hasJwk) refuse("set exactly one of assertionSecret or privateKeyJwk"); + if (hasSecret && new TextEncoder().encode(idn.assertionSecret!).length < 32) refuse("assertionSecret is too short (at least 32 bytes)"); + if (hasJwk && (typeof idn.privateKeyJwk!.d !== "string" || typeof idn.privateKeyJwk!.kid !== "string")) refuse("privateKeyJwk must be a private JWK with a kid"); + const ttl = idn.sessionTtlSeconds; + if (ttl !== undefined && (!Number.isInteger(ttl) || ttl < 60 || ttl > 43200)) refuse("sessionTtlSeconds must be an integer from 60 to 43200"); + const agent = idn.agent || o.defaultAgent || staticAgents[0]?.id; + if (!agent || !AGENT_RE.test(agent)) refuse("no agent to bind sessions to: set identity.agent, defaultAgent or agents"); + if (staticAgents.length && !staticAgents.some((a) => a.id === agent)) refuse(`agent "${agent}" is not in agents`); + return agent!; +} export function createDeweeHandler(options: DeweeHandlerOptions): Handler { const o = { @@ -78,6 +203,8 @@ export function createDeweeHandler(options: DeweeHandlerOptions): Handler { o.agents === "*" ? [] : (o.agents ?? []).map((a) => (typeof a === "string" ? { id: a } : a)); const wildcardAgents = o.agents === "*"; const byo = o.allowClientCredentials ? (o.allowClientCredentials === true ? {} : o.allowClientCredentials) : null; + const idn = o.identity; + const identityAgent = idn ? checkIdentityOptions(o, staticAgents) : ""; function serverCreds(): GatewayCredentials | null { return o.gatewayUrl && o.token ? { url: o.gatewayUrl, token: o.token, tenantId: o.tenantId } : null; @@ -142,14 +269,83 @@ export function createDeweeHandler(options: DeweeHandlerOptions): Handler { }); const fail = (req: Request, status: number, message: string, code = "bad_request") => json(req, { error: { message, code } }, status); + /** A typed identity error: `{error: {message, code, remediation}}`. Never carries a token or secret. */ + const identityFail = (req: Request, code: IdentityErrorCode, remediation?: Remediation) => { + const e = IDENTITY_ERRORS[code]; + return json(req, { error: { message: e.message, code, remediation: remediation ?? { action: e.action, audience: "end_user" } } }, e.status); + }; + + /** Gateway WebSocket failure: identity codes are forwarded typed, anything else stays a gateway error. */ + const gatewayFail = (req: Request, e: unknown) => + e instanceof GatewayError && isIdentityErrorCode(e.code) ? identityFail(req, e.code, e.remediation) : fail(req, 502, (e as Error).message, "gateway_error"); + + /** `@dewee/identity` failure (exchange, link): typed identity codes are forwarded; SDK messages are not. */ + const sdkFail = (req: Request, e: unknown, what: string) => { + const code = (e as { code?: unknown })?.code; + if (isIdentityErrorCode(code)) { + console.warn(`[dewee-webchat] identity ${what} refused: ${code}`); + return identityFail(req, code, (e as { remediation?: Remediation }).remediation); + } + console.warn(`[dewee-webchat] identity ${what} failed:`, code ?? e); + if (code === "RESOURCE_EXHAUSTED") return fail(req, 429, "Too many sign-ins, slow down", "rate_limited"); + if (code === "INVALID_ARGUMENT" || code === "SERVER_ONLY") return fail(req, 500, "Signed-in chat is misconfigured on this server", "identity_misconfigured"); + return fail(req, 502, "Dewee is unavailable, try again later", "gateway_error"); + }; + const clientIp = (req: Request) => req.headers.get("cf-connecting-ip") || req.headers.get("x-real-ip") || req.headers.get("x-forwarded-for")?.split(",")[0].trim() || "unknown"; - async function visitorFrom(req: Request): Promise { + /** The verified visitor token of the request. A token carrying an identity session is honored only in identity mode. */ + async function visitorFrom(req: Request): Promise { const auth = req.headers.get("authorization") || ""; const token = auth.startsWith("Bearer ") ? auth.slice(7) : ""; const claims = token ? await verifyVisitorToken(secret!, token) : null; - return claims?.v ?? null; + if (claims?.ids && !idn) return null; + return claims; + } + + /** Agent of a request: an identity session is bound to its agent; anything else follows the allowlist. */ + async function agentFor(claims: VisitorClaims, requested: string | null | undefined, creds: GatewayCredentials | null, isByo: boolean): Promise { + if (claims.ids) return !requested || requested === claims.ida ? claims.ida! : null; + return pickAgent(requested, creds, isByo); + } + + const actorOf = (claims: VisitorClaims): GatewayActor => (claims.ids ? { identitySession: claims.ids } : o.userIdPrefix + claims.v); + + function identitySigner(): AssertionSigner { + if (idn!.assertionSecret) return { applicationId: idn!.appId, issuer: idn!.issuer, secret: idn!.assertionSecret }; + // IdentityPrivateJwk is the JSON shape of jose's JWK, which the SDK types the key as. + return { applicationId: idn!.appId, issuer: idn!.issuer, privateKeyJwk: idn!.privateKeyJwk as AssertionSigner["privateKeyJwk"], algorithm: idn!.algorithm }; + } + + /** Exchanges a signed-in user for a Dewee identity session and wraps it in a visitor token. */ + async function issueIdentitySession(req: Request, subject: string): Promise { + let sdk: IdentitySdk; + try { + sdk = await loadIdentitySdk(); + } catch (e) { + console.error("[dewee-webchat]", (e as Error).message); + return fail(req, 500, "Signed-in chat is misconfigured on this server", "identity_misconfigured"); + } + let session: Awaited>; + try { + session = await sdk.exchangeSession({ + gatewayUrl: o.gatewayUrl!, + allowInsecureHttp: idn!.allowInsecureHttp, + signer: identitySigner(), + subject, + agent: identityAgent, + ttlSeconds: idn!.sessionTtlSeconds, + }); + } catch (e) { + return sdkFail(req, e, "session exchange"); + } + // Same principal, same visitor id: conversations follow the user across devices and page loads. + const visitorId = await deriveVisitorId(secret!, `principal:${session.principalId}`); + const expiresAt = Math.min(session.expiresAt.getTime(), Date.now() + o.sessionTtlMs); + const token = await signVisitorToken(secret!, { v: visitorId, exp: expiresAt, ids: session.sessionToken, ida: identityAgent }); + const body: SessionResponse = { visitorId, token, expiresAt, verified: true }; + return json(req, body); } async function pickAgent(requested: string | null | undefined, creds: GatewayCredentials | null, isByo: boolean): Promise { @@ -180,6 +376,12 @@ export function createDeweeHandler(options: DeweeHandlerOptions): Handler { maxMessageLength: o.maxMessageLength, version: VERSION, }; + if (idn) { + // Identity sessions are bound to one agent, so the widget must not switch. + body.defaultAgent = identityAgent; + body.allowAgentSwitch = false; + body.identity = true; + } return json(req, body); } @@ -192,16 +394,39 @@ export function createDeweeHandler(options: DeweeHandlerOptions): Handler { return fail(req, 403, "Verification failed", "turnstile_failed"); } } + if (idn) { + // Fail closed: a signed-in user gets an identity session or no session at all. Only a visitor + // who is not signed in (or whose login could not be read) falls back to anonymous, and only + // when identity is not required. + let user: AuthenticatedUser | null = null; + try { + user = await idn.authenticate(req); + } catch (e) { + console.warn("[dewee-webchat] identity.authenticate failed:", e); + if (idn.required) return identityFail(req, "SOURCE_UNAVAILABLE", { action: "retry_later", audience: "end_user" }); + } + if (user != null) { + if (typeof user !== "object" || typeof user.subject !== "string" || user.subject.trim() === "") { + console.error("[dewee-webchat] identity.authenticate must return { subject } or null"); + return fail(req, 500, "Signed-in chat is misconfigured on this server", "identity_misconfigured"); + } + return issueIdentitySession(req, user.subject); + } + if (idn.required) return identityFail(req, "IDENTITY_REQUIRED"); + } // Refresh an existing visitor instead of minting a new identity, so history survives. + // A signed-in user's token never seeds an anonymous visitor. const previous = typeof body?.token === "string" ? await verifyVisitorToken(secret!, body.token) : null; - const visitorId = previous?.v ?? randomId(16); + const visitorId = (previous && !previous.ids ? previous.v : undefined) ?? randomId(16); const expiresAt = Date.now() + o.sessionTtlMs; return json(req, { visitorId, token: await signVisitorToken(secret!, { v: visitorId, exp: expiresAt }), expiresAt }); } async function handleChat(req: Request): Promise { - const visitorId = await visitorFrom(req); - if (!visitorId) return fail(req, 401, "Missing or expired visitor session", "unauthorized"); + const claims = await visitorFrom(req); + if (!claims) return fail(req, 401, "Missing or expired visitor session", "unauthorized"); + if (idn?.required && !claims.ids) return identityFail(req, "IDENTITY_REQUIRED"); + const visitorId = claims.v; if (limiter && !(await limiter(`${visitorId}|${clientIp(req)}`, "chat"))) return fail(req, 429, "Too many messages, please wait a moment", "rate_limited"); const { creds, byo: isByo, error } = resolveCreds(req); if (error) return fail(req, 400, error, "bad_credentials"); @@ -212,7 +437,7 @@ export function createDeweeHandler(options: DeweeHandlerOptions): Handler { if (!message) return fail(req, 400, "Message is required"); if (message.length > o.maxMessageLength) return fail(req, 413, `Message exceeds ${o.maxMessageLength} characters`, "too_long"); const conversationId = typeof body?.conversationId === "string" && ID_RE.test(body.conversationId) ? body.conversationId : randomId(9); - const agent = await pickAgent(body?.agent, creds, isByo); + const agent = await agentFor(claims, body?.agent, creds, isByo); if (!agent) return fail(req, 403, "Agent not allowed", "agent_not_allowed"); // Page actions: the manifest (no handlers) and page context become a delimited block in front of the @@ -230,6 +455,17 @@ export function createDeweeHandler(options: DeweeHandlerOptions): Handler { throw e; } + // A signed-in user's session is checked before streaming starts, so an expired or revoked + // session answers with a typed HTTP error the widget can act on (re-request /session). + let connection: GatewayConnection | undefined; + if (claims.ids) { + try { + connection = await GatewayConnection.connect(creds, { identitySession: claims.ids }); + } catch (e) { + return gatewayFail(req, e); + } + } + const sessionKey = buildSessionKey(agent, `webchat-${visitorId}-${conversationId}`); const abort = new AbortController(); req.signal?.addEventListener("abort", () => abort.abort(), { once: true }); @@ -245,7 +481,8 @@ export function createDeweeHandler(options: DeweeHandlerOptions): Handler { let errMsg: string | undefined; try { await send(actionsHash ? { type: "start", conversationId, agent, actionsHash } : { type: "start", conversationId, agent }); - for await (const ev of streamChat({ creds, userId: o.userIdPrefix + visitorId, agent, sessionKey, message: pageBlock + message, signal: abort.signal, timeoutMs: o.timeoutMs })) { + const who = connection ? { connection } : { userId: o.userIdPrefix + visitorId }; + for await (const ev of streamChat({ creds, ...who, agent, sessionKey, message: pageBlock + message, signal: abort.signal, timeoutMs: o.timeoutMs })) { if (ev.type === "start") continue; // already announced with the conversation id if (ev.type === "done") reply = ev.content; if (ev.type === "error") errMsg = ev.message; @@ -256,6 +493,7 @@ export function createDeweeHandler(options: DeweeHandlerOptions): Handler { errMsg = (e as Error).message; await send({ type: "error", message: errMsg, code: "gateway_error" }); } finally { + connection?.close(); // idempotent; covers a turn that ended before the stream took the connection clearInterval(keepAlive); try { o.onChat?.({ visitorId, agent, message, reply, error: errMsg }); } catch { /* observer errors are ignored */ } writer.close().catch(() => {}); @@ -273,23 +511,55 @@ export function createDeweeHandler(options: DeweeHandlerOptions): Handler { } async function handleHistory(req: Request): Promise { - const visitorId = await visitorFrom(req); - if (!visitorId) return fail(req, 401, "Missing or expired visitor session", "unauthorized"); + const claims = await visitorFrom(req); + if (!claims) return fail(req, 401, "Missing or expired visitor session", "unauthorized"); + if (idn?.required && !claims.ids) return identityFail(req, "IDENTITY_REQUIRED"); + const visitorId = claims.v; const { creds, byo: isByo, error } = resolveCreds(req); if (error) return fail(req, 400, error, "bad_credentials"); if (!creds) return fail(req, 500, "Gateway is not configured", "not_configured"); const url = new URL(req.url); const conversationId = url.searchParams.get("conversationId") || ""; if (!ID_RE.test(conversationId)) return fail(req, 400, "Invalid conversationId"); - const agent = await pickAgent(url.searchParams.get("agent"), creds, isByo); + const agent = await agentFor(claims, url.searchParams.get("agent"), creds, isByo); if (!agent) return fail(req, 403, "Agent not allowed", "agent_not_allowed"); try { - const messages = await fetchHistory(creds, o.userIdPrefix + visitorId, agent, buildSessionKey(agent, `webchat-${visitorId}-${conversationId}`)); + const messages = await fetchHistory(creds, actorOf(claims), agent, buildSessionKey(agent, `webchat-${visitorId}-${conversationId}`)); return json(req, { messages }); } catch (e) { // A conversation that has not sent its first message yet does not exist on the gateway. if ((e as GatewayError).code === "NOT_FOUND") return json(req, { messages: [] }); - return fail(req, 502, (e as Error).message, "gateway_error"); + return gatewayFail(req, e); + } + } + + /** Web-first linking: starts a Dewee link for the signed-in user and returns the proof code. */ + async function handleLink(req: Request): Promise { + if (limiter && !(await limiter(clientIp(req), "session"))) return fail(req, 429, "Too many requests, slow down", "rate_limited"); + const claims = await visitorFrom(req); + if (!claims?.ids) return identityFail(req, "IDENTITY_REQUIRED"); + const body: any = await req.json().catch(() => ({})); + const idempotencyKey = body?.idempotencyKey; + if (idempotencyKey !== undefined && (typeof idempotencyKey !== "string" || !IDEMPOTENCY_KEY_RE.test(idempotencyKey))) { + return fail(req, 400, "idempotencyKey must be 1 to 255 printable ASCII characters without spaces"); + } + let sdk: IdentitySdk; + try { + sdk = await loadIdentitySdk(); + } catch (e) { + console.error("[dewee-webchat]", (e as Error).message); + return fail(req, 500, "Signed-in chat is misconfigured on this server", "identity_misconfigured"); + } + try { + const link = await sdk.startLink({ gatewayUrl: o.gatewayUrl!, allowInsecureHttp: idn!.allowInsecureHttp, sessionToken: claims.ids, idempotencyKey }); + const res: LinkResponse = { + proofCode: link.proofCode, + expiresAt: link.expiresAt.getTime(), + deepLinks: link.deepLinks.map((t) => ({ channelType: t.channelType, channelInstanceId: t.channelInstanceId, name: t.name, ...(t.displayName ? { displayName: t.displayName } : {}) })), + }; + return json(req, res); + } catch (e) { + return sdkFail(req, e, "link start"); } } @@ -302,6 +572,7 @@ export function createDeweeHandler(options: DeweeHandlerOptions): Handler { if (req.method === "POST" && action === "session") return await handleSession(req); if (req.method === "POST" && action === "chat") return await handleChat(req); if (req.method === "GET" && action === "history") return await handleHistory(req); + if (req.method === "POST" && action === "link" && idn) return await handleLink(req); return fail(req, 404, "Not found", "not_found"); } catch (e) { return fail(req, 500, (e as Error).message || "Internal error", "internal"); diff --git a/packages/dewee-webchat/src/server/index.ts b/packages/dewee-webchat/src/server/index.ts index 5692fc8..7dd6317 100644 --- a/packages/dewee-webchat/src/server/index.ts +++ b/packages/dewee-webchat/src/server/index.ts @@ -1,4 +1,11 @@ -export { createDeweeHandler, optionsFromEnv, type DeweeHandlerOptions } from "./handler"; +export { + createDeweeHandler, + optionsFromEnv, + type AuthenticatedUser, + type DeweeHandlerOptions, + type DeweeIdentityOptions, + type IdentityPrivateJwk, +} from "./handler"; export { toNodeHandler } from "./node"; export { GatewayConnection, @@ -8,9 +15,24 @@ export { foldHistory, listAgents, streamChat, + type GatewayActor, type GatewayCredentials, type StreamChatOptions, } from "./gateway-client"; -export { memoryRateLimiter, signVisitorToken, verifyVisitorToken, type RateLimiter } from "./security"; +export { memoryRateLimiter, signVisitorToken, verifyVisitorToken, type RateLimiter, type VisitorClaims } from "./security"; export { ACTION_LIMITS, buildPageBlock, hashManifest, normalizeManifest, stripPageBlock } from "../actions/page-block"; -export type { ActionManifestEntry, ActionSensitivity, AgentInfo, ChatRequestBody, ChatStreamEvent, HistoryMessage, PublicConfig, SessionResponse } from "../protocol"; +export type { + ActionManifestEntry, + ActionSensitivity, + AgentInfo, + ChatRequestBody, + ChatStreamEvent, + ErrorBody, + HistoryMessage, + IdentityErrorCode, + LinkResponse, + LinkTarget, + PublicConfig, + Remediation, + SessionResponse, +} from "../protocol"; diff --git a/packages/dewee-webchat/src/server/security.ts b/packages/dewee-webchat/src/server/security.ts index 4bc22d9..0e30c6d 100644 --- a/packages/dewee-webchat/src/server/security.ts +++ b/packages/dewee-webchat/src/server/security.ts @@ -24,12 +24,20 @@ function hmacKey(secret: string): Promise { } export interface VisitorClaims { - /** Visitor id (random, 22 chars). */ + /** Visitor id (random, 22 chars; derived from the principal for a signed-in user). */ v: string; /** Expiry, epoch ms. */ exp: number; + /** Signed-in users only: the Dewee identity session token (one agent, chat only, short-lived). */ + ids?: string; + /** Signed-in users only: the agent the identity session is bound to. */ + ida?: string; } +/** Longest identity session token the gateway issues (matches the `@dewee/identity` limit). */ +const MAX_IDENTITY_SESSION_LENGTH = 512; +const AGENT_CLAIM_RE = /^[A-Za-z0-9_.:-]{1,64}$/; + export async function signVisitorToken(secret: string, claims: VisitorClaims): Promise { const body = b64url(enc.encode(JSON.stringify(claims))); const sig = new Uint8Array(await crypto.subtle.sign("HMAC", await hmacKey(secret), enc.encode(body))); @@ -45,12 +53,26 @@ export async function verifyVisitorToken(secret: string, token: string): Promise const claims = JSON.parse(new TextDecoder().decode(fromB64url(body))) as VisitorClaims; if (typeof claims.v !== "string" || !/^[A-Za-z0-9_-]{8,64}$/.test(claims.v)) return null; if (typeof claims.exp !== "number" || claims.exp < Date.now()) return null; + // An identity claim is all or nothing: a session token always travels with the agent it is bound to. + if (claims.ids !== undefined || claims.ida !== undefined) { + if (typeof claims.ids !== "string" || claims.ids === "" || claims.ids.length > MAX_IDENTITY_SESSION_LENGTH) return null; + if (typeof claims.ida !== "string" || !AGENT_CLAIM_RE.test(claims.ida)) return null; + } return claims; } catch { return null; } } +/** + * Stable visitor id for a label (e.g. a Dewee principal id): HMAC(secret, label), 22 base64url chars. + * A signed-in user keeps the same conversations on every device and page load without the id being guessable. + */ +export async function deriveVisitorId(secret: string, label: string): Promise { + const mac = new Uint8Array(await crypto.subtle.sign("HMAC", await hmacKey(secret), enc.encode(`dewee-webchat:visitor:${label}`))); + return b64url(mac.slice(0, 16)); +} + export function randomId(bytes = 16): string { return b64url(crypto.getRandomValues(new Uint8Array(bytes))); } diff --git a/packages/dewee-webchat/src/widget/api-client.ts b/packages/dewee-webchat/src/widget/api-client.ts index 0e7b77c..a154861 100644 --- a/packages/dewee-webchat/src/widget/api-client.ts +++ b/packages/dewee-webchat/src/widget/api-client.ts @@ -5,10 +5,18 @@ import { type ChatRequestBody, type ChatStreamEvent, type HistoryMessage, + type LinkResponse, type PublicConfig, + type Remediation, type SessionResponse, } from "../protocol"; +/** Identity errors after which the signed-in session is dropped and `/session` is requested again. */ +const RENEW_CODES = new Set(["LINK_EXPIRED", "LINK_REVOKED"]); +/** Refresh margins: anonymous visitor tokens live 30 days, identity sessions minutes to hours. */ +const ANON_REFRESH_MS = 86_400_000; +const VERIFIED_REFRESH_MS = 60_000; + export interface ApiClientOptions { /** Backend base URL, e.g. "/api/dewee" or "https://example.com/api/dewee". */ endpoint: string; @@ -20,7 +28,7 @@ export interface ApiClientOptions { } export class ApiError extends Error { - constructor(message: string, public status: number, public code?: string) { + constructor(message: string, public status: number, public code?: string, public remediation?: Remediation) { super(message); } } @@ -60,7 +68,11 @@ export class ApiClient { private async parse(res: Response): Promise { const body: any = await res.json().catch(() => ({})); - if (!res.ok) throw new ApiError(body?.error?.message || `HTTP ${res.status}`, res.status, body?.error?.code); + if (!res.ok) { + const r = body?.error?.remediation; + const remediation = r && typeof r.action === "string" && typeof r.audience === "string" ? { action: r.action, audience: r.audience } : undefined; + throw new ApiError(body?.error?.message || `HTTP ${res.status}`, res.status, body?.error?.code, remediation); + } return body as T; } @@ -70,26 +82,46 @@ export class ApiClient { return this.config; } + private fresh(s: SessionResponse): boolean { + return s.expiresAt - Date.now() > (s.verified ? VERIFIED_REFRESH_MS : ANON_REFRESH_MS); + } + + /** The stored anonymous visitor session, if any. Signed-in sessions are never stored. */ + private stored(): SessionResponse | null { + const saved = store.get(this.storageKey); + if (!saved) return null; + try { + const s = JSON.parse(saved) as SessionResponse; + return s.verified ? null : s; + } catch { + return null; // corrupted entry + } + } + /** Returns a valid visitor session, creating or refreshing it when needed. */ ensureSession(): Promise { - if (this.session && this.session.expiresAt - Date.now() > 86_400_000) return Promise.resolve(this.session); + if (this.session && this.fresh(this.session)) return Promise.resolve(this.session); + return (this.sessionPromise ??= this.loadSession().finally(() => (this.sessionPromise = null))); + } + + private async loadSession(): Promise { if (!this.session) { - const saved = store.get(this.storageKey); - if (saved) { - try { - const s = JSON.parse(saved) as SessionResponse; - if (s.expiresAt - Date.now() > 86_400_000) return Promise.resolve((this.session = s)); - this.session = s; // close to expiry: refresh below, keeping the same visitor id - } catch { /* corrupted entry */ } + const s = this.stored(); + if (s) { + // A backend that signs users in decides who the visitor is on every page load: the + // stored anonymous token only seeds the visitor id when the user is not signed in. + if (this.fresh(s) && !(await this.getConfig()).identity) return (this.session = s); + this.session = s; // close to expiry: refresh below, keeping the same visitor id } } - return (this.sessionPromise ??= this.createSession().finally(() => (this.sessionPromise = null))); + return this.createSession(); } private async createSession(): Promise { const cfg = await this.getConfig(); const body: Record = {}; - if (this.session?.token) body.token = this.session.token; + const previous = this.session && !this.session.verified ? this.session : this.stored(); + if (previous?.token) body.token = previous.token; if (cfg.turnstileSiteKey) { if (!this.o.turnstile) throw new ApiError("Verification is required but no Turnstile handler is set", 403, "turnstile_required"); body.turnstileToken = await this.o.turnstile(cfg.turnstileSiteKey); @@ -98,15 +130,38 @@ export class ApiClient { await fetch(this.url("session"), { method: "POST", headers: this.headers(), body: JSON.stringify(body) }), ); this.session = s; - store.set(this.storageKey, JSON.stringify(s)); + // A signed-in session stays in memory only: the next page load asks the backend again, so a + // sign-out or a different user never inherits it. + if (!s.verified) store.set(this.storageKey, JSON.stringify(s)); return s; } + /** Drops the session; the next request creates a new one. A stored anonymous visitor is kept for a signed-in session. */ forgetSession(): void { + if (this.session?.verified) { + this.session = null; + return; + } this.session = null; store.del(this.storageKey); } + /** + * Asks the backend for a new session now (after a sign-in or sign-out on the host page). The + * stored anonymous visitor id is kept, so an anonymous visitor's history survives. + */ + async renewSession(): Promise { + this.session = null; + return this.ensureSession(); + } + + /** Starts a web-first link of a chat account for the signed-in user (identity backends only). */ + async link(): Promise { + await this.ensureSession(); + const res = await fetch(this.url("link"), { method: "POST", headers: this.headers(true), body: "{}" }); + return this.parse(res); + } + async history(agent: string, conversationId: string): Promise { await this.ensureSession(); const res = await fetch(this.url("history", { agent, conversationId }), { headers: this.headers(true) }); @@ -130,6 +185,7 @@ export class ApiClient { const reader = res.body!.pipeThrough(new TextDecoderStream()).getReader(); let buf = ""; + let renew = false; while (true) { const { done, value } = await reader.read(); if (done) break; @@ -140,8 +196,16 @@ export class ApiClient { buf = buf.slice(idx + 2); const data = frame.split("\n").filter((l) => l.startsWith("data:")).map((l) => l.slice(5).trimStart()).join("\n"); if (!data) continue; - try { onEvent(JSON.parse(data) as ChatStreamEvent); } catch { /* malformed frame */ } + let event: ChatStreamEvent; + try { event = JSON.parse(data) as ChatStreamEvent; } catch { continue; /* malformed frame */ } + if (event.type === "error" && event.code && RENEW_CODES.has(event.code)) renew = true; + try { onEvent(event); } catch { /* a failing listener never breaks the stream */ } } } + // The signed-in session ended during the run: ask /session again so the next message uses a fresh one. + if (renew && this.session?.verified) { + this.forgetSession(); + await this.ensureSession().catch(() => { /* reported on the next message */ }); + } } } diff --git a/packages/dewee-webchat/src/widget/element.ts b/packages/dewee-webchat/src/widget/element.ts index ff14e98..2a1f379 100644 --- a/packages/dewee-webchat/src/widget/element.ts +++ b/packages/dewee-webchat/src/widget/element.ts @@ -1,5 +1,5 @@ // — framework-agnostic chat widget (Shadow DOM, zero dependencies). -import type { ActionSensitivity, AgentInfo, ChatRequestBody, ChatStreamEvent, HistoryMessage, PublicConfig, ToolCallView } from "../protocol"; +import { isIdentityErrorCode, type ActionSensitivity, type AgentInfo, type ChatRequestBody, type ChatStreamEvent, type HistoryMessage, type LinkResponse, type PublicConfig, type ToolCallView } from "../protocol"; import { ActionRegistry, runActionCall, type ActionCallStatus, type ActionConfirmHook, type PageAction, type PageActionInfo } from "../actions/action-runner"; import { formatActionResults, @@ -412,6 +412,76 @@ export class DeweeChatElement extends ElementBase { this.running?.abort(); } + /** + * Call after the visitor signs in or out on the host page: the next message uses a session for + * the current user, and the conversation is reloaded for them. + */ + async renewSession(): Promise { + this.stop(); + try { + await this.client().renewSession(); + } catch (e) { + this.showError(null, e as Error); + return; + } + if (this.activated) await this.loadConversation(); + } + + /** + * Web-first linking for a signed-in user: shows a proof code and the chat apps to send it to + * ("Continue on Telegram"). The user never types an account id. Resolves null on failure (the + * error is shown in the chat and emitted as `dewee:error`). + */ + async startLink(): Promise { + if (this.config.mode !== "inline" && !this.hasAttribute("data-open")) this.open(); + await this.activate(); + try { + const link = await this.client().link(); + this.renderLink(link); + this.emit("dewee:link", { expiresAt: link.expiresAt, deepLinks: link.deepLinks }); + return link; + } catch (e) { + this.showError(null, e as Error); + return null; + } + } + + private renderLink(link: LinkResponse) { + const s = this.strings; + const root = document.createElement("div"); + root.className = "msg assistant link"; + const bubble = document.createElement("div"); + bubble.className = "bubble"; + const md = document.createElement("div"); + md.className = "md"; + const p = (text: string) => { + const el = document.createElement("p"); + el.textContent = text; + return el; + }; + const code = document.createElement("code"); + code.textContent = link.proofCode; + const codeLine = document.createElement("p"); + codeLine.appendChild(code); + md.append(p(s.linkIntro), codeLine); + if (link.deepLinks.length) { + const list = document.createElement("ul"); + for (const t of link.deepLinks) { + const li = document.createElement("li"); + const label = t.displayName || t.name; + li.textContent = s.linkContinueOn(channelLabel(t.channelType)) + (label ? ` · ${label}` : ""); + list.appendChild(li); + } + md.appendChild(list); + } else md.appendChild(p(s.linkNoTargets)); + md.appendChild(p(s.linkExpires(Math.max(1, Math.round((link.expiresAt - Date.now()) / 60_000))))); + bubble.appendChild(md); + root.appendChild(bubble); + if (!this.messages.length) this.els.log.replaceChildren(); + this.els.log.appendChild(root); + this.scrollToBottom(true); + } + /** Starts a fresh conversation with the current agent. */ newConversation(): void { this.stop(); @@ -860,7 +930,7 @@ export class DeweeChatElement extends ElementBase { break; case "error": m.status = "error"; - this.showError(m, new ApiError(e.message, 500, e.code)); + this.showError(m, new ApiError(e.message, 500, e.code, e.remediation)); break; } this.scheduleUpdate(m); @@ -895,8 +965,11 @@ export class DeweeChatElement extends ElementBase { } private showError(m: ViewMessage | null, err: Error) { - const text = err.message || this.strings.errorGeneric; - this.emit("dewee:error", { message: text, code: (err as ApiError).code }); + const code = (err as ApiError).code; + // Identity errors get the widget's own localized copy; the server text is only a fallback. + const text = (isIdentityErrorCode(code) ? this.strings.identityErrors[code] : "") || err.message || this.strings.errorGeneric; + const remediation = (err as ApiError).remediation; + this.emit("dewee:error", remediation ? { message: text, code, remediation } : { message: text, code }); if (m) { m.content = m.content ? `${m.content}\n\n> ⚠️ ${text}` : `⚠️ ${text}`; m.status = "error"; @@ -955,6 +1028,13 @@ function decorateHistory(messages: ViewMessage[]): ViewMessage[] { return messages; } +const CHANNEL_LABELS: Record = { telegram: "Telegram", discord: "Discord", zalo: "Zalo", slack: "Slack", whatsapp: "WhatsApp", messenger: "Messenger", feishu: "Feishu", line: "LINE" }; + +function channelLabel(type: string): string { + const key = type.toLowerCase().split(/[_-]/)[0]; + return CHANNEL_LABELS[key] ?? (type ? type.charAt(0).toUpperCase() + type.slice(1) : "chat"); +} + function safeJson(s: string, fallback: T): T { try { return JSON.parse(s); } catch { return fallback; } } diff --git a/packages/dewee-webchat/src/widget/i18n.ts b/packages/dewee-webchat/src/widget/i18n.ts index e5f97c0..2eeddca 100644 --- a/packages/dewee-webchat/src/widget/i18n.ts +++ b/packages/dewee-webchat/src/widget/i18n.ts @@ -1,3 +1,5 @@ +import type { IdentityErrorCode } from "../protocol"; + export interface Strings { title: string; greeting: string; @@ -26,6 +28,12 @@ export interface Strings { allow: string; deny: string; actionLimit: (n: number) => string; + /** Shown for the typed identity errors of a signed-in chat. */ + identityErrors: Record; + linkIntro: string; + linkContinueOn: (channel: string) => string; + linkExpires: (minutes: number) => string; + linkNoTargets: string; } const en: Strings = { @@ -56,6 +64,19 @@ const en: Strings = { allow: "Allow", deny: "Deny", actionLimit: (n) => `Stopped after ${n} automatic action rounds. Send a message to continue.`, + identityErrors: { + IDENTITY_REQUIRED: "Please sign in to chat.", + IDENTITY_LINK_REQUIRED: "Link your chat account to continue.", + IDENTITY_CONFLICT: "This chat account is already linked to another user. Unlink it there first.", + LINK_EXPIRED: "Your session expired. Send your message again.", + LINK_REVOKED: "Your access has ended. Sign in again to continue.", + PERMISSION_DENIED: "You don't have access to this.", + SOURCE_UNAVAILABLE: "We couldn't check your sign-in right now. Please try again shortly.", + }, + linkIntro: "Send this code in a direct message from the chat account you want to link:", + linkContinueOn: (channel) => `Continue on ${channel}`, + linkExpires: (m) => `The code expires in ${m} min.`, + linkNoTargets: "No chat app is set up for linking yet.", }; const vi: Strings = { @@ -86,6 +107,19 @@ const vi: Strings = { allow: "Cho phép", deny: "Từ chối", actionLimit: (n) => `Đã dừng sau ${n} lượt thao tác tự động. Gửi tin nhắn để tiếp tục.`, + identityErrors: { + IDENTITY_REQUIRED: "Bạn đăng nhập để trò chuyện nhé.", + IDENTITY_LINK_REQUIRED: "Bạn cần liên kết tài khoản chat để tiếp tục.", + IDENTITY_CONFLICT: "Tài khoản chat này đã liên kết với người dùng khác. Hãy hủy liên kết ở đó trước.", + LINK_EXPIRED: "Phiên đăng nhập đã hết hạn. Bạn gửi lại tin nhắn giúp mình nhé.", + LINK_REVOKED: "Quyền truy cập của bạn đã kết thúc. Hãy đăng nhập lại để tiếp tục.", + PERMISSION_DENIED: "Bạn không có quyền thực hiện việc này.", + SOURCE_UNAVAILABLE: "Hiện chưa kiểm tra được đăng nhập của bạn. Bạn thử lại sau ít phút nhé.", + }, + linkIntro: "Gửi mã này trong tin nhắn riêng từ tài khoản chat bạn muốn liên kết:", + linkContinueOn: (channel) => `Tiếp tục trên ${channel}`, + linkExpires: (m) => `Mã hết hạn sau ${m} phút.`, + linkNoTargets: "Chưa có ứng dụng chat nào được thiết lập để liên kết.", }; export const locales: Record = { en, vi }; diff --git a/packages/dewee-webchat/test/identity.test.ts b/packages/dewee-webchat/test/identity.test.ts new file mode 100644 index 0000000..5120754 --- /dev/null +++ b/packages/dewee-webchat/test/identity.test.ts @@ -0,0 +1,412 @@ +// Signed-in users through a Dewee identity session exchange, against a fake gateway that serves the +// identity HTTP routes and the WS v3 protocol on one port. Covers isolation between users, fail-closed +// session issuance, typed error forwarding, web-first linking and secret hygiene. +import { createHmac, generateKeyPairSync, randomUUID, verify as verifySignature, type KeyObject } from "node:crypto"; +import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http"; +import type { AddressInfo } from "node:net"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; +import { WebSocketServer } from "ws"; +import type { ChatStreamEvent } from "../src/protocol"; +import { createDeweeHandler, signVisitorToken, type DeweeHandlerOptions, type DeweeIdentityOptions } from "../src/server"; +import { ApiClient } from "../src/widget/api-client"; +import { pickStrings } from "../src/widget/i18n"; + +const GW_TOKEN = "gw-token-NEVER-IN-BROWSER-0001"; +const ASSERTION_SECRET = "assertion-secret-NEVER-IN-BROWSER-0000000001"; +const VISITOR_SECRET = "visitor-secret-NEVER-IN-BROWSER-00000000001"; +const APP_ID = "8f0c6a2e-3b1d-4c5e-9a7f-1b2c3d4e5f60"; +const ISSUER = "https://host.example"; + +const ec = generateKeyPairSync("ec", { namedCurve: "P-256" }); +const privateJwk = { ...(ec.privateKey.export({ format: "jwk" }) as Record), kid: "k1", alg: "ES256" } as { kty: string; kid: string; d: string; alg: string }; +const ecPublic: KeyObject = ec.publicKey; + +// ---------- fake gateway ---------- + +interface Session { principal: string; agent: string; expiresAt: number } +const sessions = new Map(); +const expiredSessions = new Set(); +const revokeDuringRun = new Set(); +const seen = { connects: [] as any[], sends: [] as any[], exchanges: [] as { sub: string; agent: string; ttl?: number }[] }; +let gatewayUrl = ""; +let server: Server; + +const b64json = (s: string) => JSON.parse(Buffer.from(s, "base64url").toString("utf8")); + +/** Checks a partner assertion the way the gateway does (HS256 with the secret, or ES256 with the JWKS key). */ +function verifyAssertion(jws: string): { sub: string } | null { + const [h, p, s] = jws.split("."); + if (!h || !p || !s) return null; + const header = b64json(h); + const claims = b64json(p); + let ok = false; + if (header.alg === "HS256") ok = createHmac("sha256", ASSERTION_SECRET).update(`${h}.${p}`).digest("base64url") === s; + if (header.alg === "ES256") ok = verifySignature("sha256", Buffer.from(`${h}.${p}`), { key: ecPublic, dsaEncoding: "ieee-p1363" }, Buffer.from(s, "base64url")); + if (!ok || header.typ !== "dewee-session+jwt" || claims.aud !== `dewee:${APP_ID}` || claims.iss !== ISSUER) return null; + return { sub: claims.sub }; +} + +const identityError = (code: string, action: string) => ({ error: { code, message: code, type: "identity_error", remediation: { action, audience: "end_user" } } }); + +function sendJson(res: ServerResponse, status: number, body: unknown) { + res.writeHead(status, { "content-type": "application/json" }); + res.end(JSON.stringify(body)); +} + +async function onHttp(req: IncomingMessage, res: ServerResponse) { + const chunks: Buffer[] = []; + for await (const c of req) chunks.push(c as Buffer); + const body = chunks.length ? JSON.parse(Buffer.concat(chunks).toString("utf8")) : {}; + if (req.method === "POST" && req.url === "/v1/identity/sessions/exchange") { + const auth = String(req.headers.authorization ?? ""); + const proof = auth.startsWith("Dewee-Assertion ") ? verifyAssertion(auth.slice(16)) : null; + if (!proof) return sendJson(res, 401, identityError("IDENTITY_REQUIRED", "reauthenticate")); + seen.exchanges.push({ sub: proof.sub, agent: body.agent_id, ttl: body.ttl_seconds }); + if (proof.sub === "suspended-user") return sendJson(res, 409, identityError("LINK_REVOKED", "reauthenticate")); + const token = `idsess_${randomUUID()}`; + const expiresAt = Date.now() + (body.ttl_seconds ?? 3600) * 1000; + // One principal per subject: a stable fake id derived from it. + const principal = createHmac("sha256", "principal").update(proof.sub).digest("hex").slice(0, 32); + sessions.set(token, { principal, agent: body.agent_id, expiresAt }); + return sendJson(res, 201, { + session_token: token, token_type: "identity_session", header: "X-Dewee-Identity-Session", session_id: randomUUID(), + principal_id: principal, application_id: APP_ID, agent_id: randomUUID(), expires_at: new Date(expiresAt).toISOString(), test_mode: false, + }); + } + if (req.method === "POST" && req.url === "/v1/identity/links") { + const s = sessions.get(String(req.headers["x-dewee-identity-session"] ?? "")); + if (!s) return sendJson(res, 401, identityError("IDENTITY_REQUIRED", "reauthenticate")); + return sendJson(res, 201, { + transaction_id: randomUUID(), kind: "web_first", state: "awaiting_channel_proof", proof_code: "482913", + expires_at: new Date(Date.now() + 600_000).toISOString(), created: true, + deep_links: [{ channel_type: "telegram", channel_instance_id: randomUUID(), name: "acme-bot", display_name: "Acme Bot" }], + }); + } + sendJson(res, 404, { error: { code: "NOT_FOUND", message: "not found" } }); +} + +beforeAll(async () => { + server = createServer((req, res) => void onHttp(req, res)); + const wss = new WebSocketServer({ server, path: "/ws" }); + wss.on("connection", (ws) => { + let user = ""; + let sessionToken = ""; + ws.on("message", (raw) => { + const f = JSON.parse(String(raw)); + const ok = (payload: unknown) => ws.send(JSON.stringify({ type: "res", id: f.id, ok: true, payload })); + const err = (code: string, action?: string) => + ws.send(JSON.stringify({ type: "res", id: f.id, ok: false, error: { code, message: `${code} (gateway)`, ...(action ? { details: { remediation: { action, audience: "end_user" } } } : {}) } })); + if (f.method === "connect") { + seen.connects.push(f.params); + if (f.params.token !== GW_TOKEN) return err("UNAUTHORIZED"); + if (f.params.identity_session !== undefined) { + const s = sessions.get(f.params.identity_session); + if (expiredSessions.has(f.params.identity_session)) return err("LINK_EXPIRED", "reauthenticate"); + if (!s) return err("IDENTITY_REQUIRED", "reauthenticate"); + if (f.params.user_id !== undefined && f.params.user_id !== `tuc:${s.principal}`) return err("IDENTITY_CONFLICT", "contact_admin"); + user = `tuc:${s.principal}`; + sessionToken = f.params.identity_session; + } else user = f.params.user_id; + return ok({ role: "operator" }); + } + if (f.method === "chat.send") { + seen.sends.push({ ...f.params, user }); + if (sessionToken && sessions.get(sessionToken)?.agent !== f.params.agentId) return err("PERMISSION_DENIED", "contact_admin"); + const base = { agentId: f.params.agentId, runId: "run-1", sessionKey: f.params.sessionKey }; + ws.send(JSON.stringify({ type: "event", event: "agent", payload: { ...base, type: "run.started", payload: {} } })); + if (revokeDuringRun.has(sessionToken)) return err("LINK_REVOKED", "reauthenticate"); + ws.send(JSON.stringify({ type: "event", event: "agent", payload: { ...base, type: "chunk", payload: { content: `hello ${user}` } } })); + return ok({ content: `hello ${user}` }); + } + if (f.method === "chat.history") return ok({ messages: [{ role: "user", content: `history of ${user}` }] }); + ok({}); + }); + }); + await new Promise((r) => server.listen(0, "127.0.0.1", () => r())); + gatewayUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; +}); + +afterAll(() => new Promise((r) => server.close(() => r()))); + +// ---------- handler under test ---------- + +/** Every body this file's handlers sent to a browser; checked for secrets at the end. */ +const browserBodies: string[] = []; + +const cookieUser = async (req: Request) => { + const uid = /(?:^|;\s*)uid=([^;]+)/.exec(req.headers.get("cookie") ?? "")?.[1]; + if (uid === "boom") throw new Error("login store down"); + return uid ? { subject: uid } : null; +}; + +function makeHandler(identity: Partial = {}, extra: Partial = {}) { + const h = createDeweeHandler({ + gatewayUrl, + token: GW_TOKEN, + secret: VISITOR_SECRET, + agents: ["support", "sales"], + rateLimit: false, + identity: { authenticate: cookieUser, appId: APP_ID, issuer: ISSUER, assertionSecret: ASSERTION_SECRET, ...identity }, + ...extra, + }); + return async (req: Request) => { + const res = await h(req); + const text = await res.text(); + browserBodies.push(text, JSON.stringify(Object.fromEntries(res.headers))); + return { status: res.status, text, json: () => JSON.parse(text) }; + }; +} +type TestHandler = ReturnType; + +const BASE = "https://site.test/api/dewee"; +const session = (h: TestHandler, uid?: string, body: unknown = {}) => + h(new Request(`${BASE}/session`, { method: "POST", headers: uid ? { cookie: `uid=${uid}` } : {}, body: JSON.stringify(body) })); +const chat = (h: TestHandler, token: string, body: Record = {}, cookie?: string) => + h(new Request(`${BASE}/chat`, { method: "POST", headers: { authorization: `Bearer ${token}`, ...(cookie ? { cookie } : {}) }, body: JSON.stringify({ message: "hi", conversationId: "conv_ident1", ...body }) })); +const sse = (text: string): ChatStreamEvent[] => text.split("\n\n").filter((f) => f.startsWith("data:")).map((f) => JSON.parse(f.slice(5))); +const claimsOf = (token: string) => b64json(token.split(".")[0]); + +describe("identity sessions: isolation", () => { + it("gives users A and B different identity sessions and never sends a client-chosen user_id", async () => { + const h = makeHandler(); + const a = (await session(h, "user-a")).json(); + const b = (await session(h, "user-b")).json(); + expect(a.verified).toBe(true); + expect(b.verified).toBe(true); + expect(claimsOf(a.token).ids).not.toBe(claimsOf(b.token).ids); + expect(a.visitorId).not.toBe(b.visitorId); + + const ra = sse((await chat(h, a.token, { agent: "support", userId: "evil" })).text); + const connA = seen.connects.at(-1); + expect(connA).toEqual({ token: GW_TOKEN, identity_session: claimsOf(a.token).ids }); + expect(connA).not.toHaveProperty("user_id"); + const rb = sse((await chat(h, b.token)).text); + expect(seen.connects.at(-1).identity_session).toBe(claimsOf(b.token).ids); + expect(ra.at(-1)).toMatchObject({ type: "done" }); + expect((ra.at(-1) as any).content).not.toBe((rb.at(-1) as any).content); + }); + + it("keeps chatting as A when A's visitor token is replayed with B's cookie (no switch)", async () => { + const h = makeHandler(); + const a = (await session(h, "user-a")).json(); + const asA = sse((await chat(h, a.token)).text).at(-1) as any; + const replay = sse((await chat(h, a.token, {}, "uid=user-b")).text).at(-1) as any; + expect(seen.connects.at(-1).identity_session).toBe(claimsOf(a.token).ids); + expect(replay.content).toBe(asA.content); + }); + + it("keeps the same visitor id for the same user across sessions, so history follows them", async () => { + const h = makeHandler(); + const first = (await session(h, "user-a")).json(); + const second = (await session(h, "user-a")).json(); + expect(second.visitorId).toBe(first.visitorId); + expect(claimsOf(second.token).ids).not.toBe(claimsOf(first.token).ids); + }); + + it("answers 401 to a tampered or foreign-signed token", async () => { + const h = makeHandler(); + const a = (await session(h, "user-a")).json(); + const b = (await session(h, "user-b")).json(); + const [, sig] = a.token.split("."); + const swapped = Buffer.from(JSON.stringify({ ...claimsOf(a.token), ids: claimsOf(b.token).ids })).toString("base64url"); + expect((await chat(h, `${swapped}.${sig}`)).status).toBe(401); + const forged = await signVisitorToken("another-secret-of-at-least-32-chars!!", { v: "visitor_123", exp: Date.now() + 60_000, ids: claimsOf(b.token).ids, ida: "support" }); + expect((await chat(h, forged)).status).toBe(401); + }); + + it("binds the session to one agent", async () => { + const h = makeHandler(); + const cfg = (await h(new Request(`${BASE}/config`))).json(); + expect(cfg).toMatchObject({ identity: true, allowAgentSwitch: false, defaultAgent: "support" }); + const a = (await session(h, "user-a")).json(); + expect(seen.exchanges.at(-1)).toMatchObject({ sub: "user-a", agent: "support" }); + expect((await chat(h, a.token, { agent: "sales" })).status).toBe(403); + }); +}); + +describe("identity sessions: fail closed", () => { + it("refuses identity mode combined with client credentials and other misconfiguration", () => { + const base = { gatewayUrl: "https://gw.example.com", token: GW_TOKEN, secret: VISITOR_SECRET, agents: ["support"] }; + const identity = { authenticate: cookieUser, appId: APP_ID, issuer: ISSUER, assertionSecret: ASSERTION_SECRET }; + expect(() => createDeweeHandler({ ...base, identity, allowClientCredentials: true })).toThrow(/allowClientCredentials/); + expect(() => createDeweeHandler({ ...base, identity, allowClientCredentials: { allowedHosts: ["gw.example.com"] } })).toThrow(/allowClientCredentials/); + expect(() => createDeweeHandler({ ...base, token: undefined, identity })).toThrow(/gatewayUrl and token/); + expect(() => createDeweeHandler({ ...base, identity: { ...identity, privateKeyJwk: privateJwk } })).toThrow(/exactly one/); + expect(() => createDeweeHandler({ ...base, identity: { ...identity, audience: "dewee:someone-else" } })).toThrow(/audience/); + expect(() => createDeweeHandler({ ...base, identity: { ...identity, appId: "acme" } })).toThrow(/appId/); + expect(() => createDeweeHandler({ ...base, identity: { ...identity, agent: "admin" } })).toThrow(/not in agents/); + expect(() => createDeweeHandler({ ...base, identity: { ...identity, assertionSecret: "short" } })).toThrow(/too short/); + // The error never echoes a secret. + try { createDeweeHandler({ ...base, identity, allowClientCredentials: true }); } catch (e) { expect(String(e)).not.toContain(ASSERTION_SECRET); } + }); + + it("falls back to an anonymous visitor only when identity is not required", async () => { + const h = makeHandler(); + for (const uid of [undefined, "boom"]) { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const s = (await session(h, uid)).json(); + warn.mockRestore(); + expect(s.verified).toBeUndefined(); + expect(claimsOf(s.token)).not.toHaveProperty("ids"); + await chat(h, s.token); + expect(seen.connects.at(-1)).toEqual({ token: GW_TOKEN, user_id: `webchat-${s.visitorId}` }); + } + }); + + it("issues no session at all when identity is required and the user is not signed in", async () => { + const h = makeHandler({ required: true }); + const none = await session(h); + expect(none.status).toBe(401); + expect(none.json()).toEqual({ error: { message: expect.any(String), code: "IDENTITY_REQUIRED", remediation: { action: "reauthenticate", audience: "end_user" } } }); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const boom = await session(h, "boom"); + warn.mockRestore(); + expect(boom.status).toBe(503); + expect(boom.json().error.code).toBe("SOURCE_UNAVAILABLE"); + expect(boom.json()).not.toHaveProperty("token"); + // An anonymous visitor token from before the switch is refused too. + const anon = await signVisitorToken(VISITOR_SECRET, { v: "visitor_123", exp: Date.now() + 60_000 }); + const r = await chat(h, anon); + expect([r.status, r.json().error.code]).toEqual([401, "IDENTITY_REQUIRED"]); + }); + + it("never downgrades a signed-in user whose exchange is refused", async () => { + const h = makeHandler(); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const r = await session(h, "suspended-user"); + const bad = await makeHandler({ assertionSecret: "wrong-secret-but-long-enough-0000000000001" })( + new Request(`${BASE}/session`, { method: "POST", headers: { cookie: "uid=user-a" }, body: "{}" }), + ); + const email = await session(h, "someone@example.com"); + warn.mockRestore(); + expect(r.status).toBe(401); + expect(r.json()).toEqual({ error: { message: expect.any(String), code: "LINK_REVOKED", remediation: { action: "reauthenticate", audience: "end_user" } } }); + expect([bad.status, bad.json().error.code]).toEqual([401, "IDENTITY_REQUIRED"]); + expect([email.status, email.json().error.code]).toEqual([500, "identity_misconfigured"]); + for (const res of [r, bad, email]) expect(res.json()).not.toHaveProperty("token"); + }); + + it("caps the visitor token at the identity session expiry", async () => { + const short = (await session(makeHandler({ sessionTtlSeconds: 120 }), "user-a")).json(); + expect(seen.exchanges.at(-1)?.ttl).toBe(120); + expect(short.expiresAt).toBeLessThanOrEqual(Date.now() + 120_000); + expect(short.expiresAt).toBeGreaterThan(Date.now() + 100_000); + expect(claimsOf(short.token).exp).toBe(short.expiresAt); + const visitorCap = (await session(makeHandler({}, { sessionTtlMs: 30_000 }), "user-a")).json(); + expect(visitorCap.expiresAt).toBeLessThanOrEqual(Date.now() + 30_000); + }); + + it("anonymous handlers ignore identity tokens and expose no link route", async () => { + const h = createDeweeHandler({ gatewayUrl, token: GW_TOKEN, secret: VISITOR_SECRET, agents: ["support"], rateLimit: false }); + const withIds = await signVisitorToken(VISITOR_SECRET, { v: "visitor_123", exp: Date.now() + 60_000, ids: "idsess_x", ida: "support" }); + const r = await h(new Request(`${BASE}/chat`, { method: "POST", headers: { authorization: `Bearer ${withIds}` }, body: JSON.stringify({ message: "hi" }) })); + expect(r.status).toBe(401); + expect((await h(new Request(`${BASE}/link`, { method: "POST", body: "{}" }))).status).toBe(404); + const cfg = await (await h(new Request(`${BASE}/config`))).json(); + expect(cfg).not.toHaveProperty("identity"); + }); +}); + +describe("identity sessions: typed gateway errors", () => { + it("forwards LINK_EXPIRED at connect as a typed 401 for chat and history", async () => { + const h = makeHandler(); + const a = (await session(h, "user-a")).json(); + expiredSessions.add(claimsOf(a.token).ids); + const r = await chat(h, a.token); + expect(r.status).toBe(401); + expect(r.json()).toEqual({ error: { message: expect.any(String), code: "LINK_EXPIRED", remediation: { action: "reauthenticate", audience: "end_user" } } }); + const hist = await h(new Request(`${BASE}/history?agent=support&conversationId=conv_ident1`, { headers: { authorization: `Bearer ${a.token}` } })); + expect([hist.status, hist.json().error.code]).toEqual([401, "LINK_EXPIRED"]); + }); + + it("forwards an identity error raised during the run in the event stream", async () => { + const h = makeHandler(); + const a = (await session(h, "user-a")).json(); + revokeDuringRun.add(claimsOf(a.token).ids); + const events = sse((await chat(h, a.token)).text); + expect(events.at(-1)).toEqual({ type: "error", message: expect.any(String), code: "LINK_REVOKED", remediation: { action: "reauthenticate", audience: "end_user" } }); + }); + + it("reads history as the session's user", async () => { + const h = makeHandler(); + const a = (await session(h, "user-a")).json(); + const r = await h(new Request(`${BASE}/history?agent=support&conversationId=conv_ident1`, { headers: { authorization: `Bearer ${a.token}` } })); + expect(r.json().messages[0].content).toMatch(/^history of tuc:/); + expect(seen.connects.at(-1)).not.toHaveProperty("user_id"); + }); +}); + +describe("web-first linking", () => { + it("returns the proof code and the chat accounts to send it to", async () => { + const h = makeHandler(); + const a = (await session(h, "user-a")).json(); + const r = await h(new Request(`${BASE}/link`, { method: "POST", headers: { authorization: `Bearer ${a.token}` }, body: JSON.stringify({ idempotencyKey: "req-1" }) })); + expect(r.status).toBe(200); + const body = r.json(); + expect(body).toMatchObject({ proofCode: "482913", deepLinks: [{ channelType: "telegram", name: "acme-bot", displayName: "Acme Bot" }] }); + expect(body.expiresAt).toBeGreaterThan(Date.now()); + }); + + it("refuses visitors who are not signed in", async () => { + const h = makeHandler(); + const anon = (await session(h)).json(); + const r = await h(new Request(`${BASE}/link`, { method: "POST", headers: { authorization: `Bearer ${anon.token}` }, body: "{}" })); + expect([r.status, r.json().error.code]).toEqual([401, "IDENTITY_REQUIRED"]); + }); +}); + +describe("jwks applications", () => { + it("signs with a private JWK", async () => { + const h = makeHandler({ assertionSecret: undefined, privateKeyJwk: privateJwk }); + const s = await session(h, "user-c"); + expect(s.status).toBe(200); + expect(s.json().verified).toBe(true); + }); +}); + +describe("widget client", () => { + it("re-requests /session on LINK_EXPIRED and keeps signed-in sessions out of storage", async () => { + const storage = new Map(); + vi.stubGlobal("localStorage", { getItem: (k: string) => storage.get(k) ?? null, setItem: (k: string, v: string) => void storage.set(k, v), removeItem: (k: string) => void storage.delete(k) }); + const calls: string[] = []; + let chats = 0; + const json = (status: number, v: unknown) => new Response(JSON.stringify(v), { status, headers: { "content-type": "application/json" } }); + vi.stubGlobal("fetch", vi.fn(async (url: string, init?: RequestInit) => { + const path = new URL(url, "https://site.test").pathname.split("/").pop()!; + calls.push(path); + if (path === "config") return json(200, { agents: [{ id: "support" }], defaultAgent: "support", allowAgentSwitch: false, allowClientCredentials: false, maxMessageLength: 4000, version: "t", identity: true }); + if (path === "session") return json(200, { visitorId: "v_signed_in1", token: `tok${calls.length}`, expiresAt: Date.now() + 3_600_000, verified: true }); + if (path === "chat" && ++chats === 1) return json(401, { error: { message: "expired", code: "LINK_EXPIRED", remediation: { action: "reauthenticate", audience: "end_user" } } }); + expect((init?.headers as Record).authorization).toMatch(/^Bearer tok/); + return new Response(`data: ${JSON.stringify({ type: "done", content: "ok" })}\n\n`, { headers: { "content-type": "text/event-stream" } }); + })); + try { + const api = new ApiClient({ endpoint: "/api/dewee" }); + const events: ChatStreamEvent[] = []; + await api.chat({ message: "hi", agent: "support", conversationId: "conv_ident1" }, (e) => events.push(e)); + expect(calls).toEqual(["config", "session", "chat", "session", "chat"]); + expect(events).toEqual([{ type: "done", content: "ok" }]); + expect([...storage.keys()]).toEqual([]); + } finally { + vi.unstubAllGlobals(); + } + }); + + it("has localized copy for every identity error", () => { + for (const locale of ["en", "vi"]) { + const s = pickStrings(locale); + for (const code of ["IDENTITY_REQUIRED", "IDENTITY_LINK_REQUIRED", "IDENTITY_CONFLICT", "LINK_EXPIRED", "LINK_REVOKED", "PERMISSION_DENIED", "SOURCE_UNAVAILABLE"] as const) { + expect(s.identityErrors[code]).toBeTruthy(); + } + } + expect(pickStrings("vi").identityErrors.LINK_EXPIRED).not.toBe(pickStrings("en").identityErrors.LINK_EXPIRED); + }); +}); + +describe("secret hygiene", () => { + it("never sends the gateway token, assertion secret, visitor secret or private key to a browser", () => { + expect(browserBodies.length).toBeGreaterThan(40); + const all = browserBodies.join("\n"); + for (const secret of [GW_TOKEN, ASSERTION_SECRET, VISITOR_SECRET, privateJwk.d]) expect(all).not.toContain(secret); + }); +}); From 17af0a5a96501c2ddaaaf5596b2488223b8cd3d5 Mon Sep 17 00:00:00 2001 From: Duy Nguyen Date: Tue, 20 Oct 2026 17:21:19 +0700 Subject: [PATCH 3/7] docs: document signed-in users and the identity routes README "Signed-in users" (setup, options, fail-closed rules, linking) and the HTTP contract for the identity additions to /config, /session, /chat, /history, the typed identity errors and POST /link. The PHP backend does not support signed-in users yet. Co-Authored-By: Claude Opus 5.5 --- .env.example | 5 ++ README.md | 16 +++++++ docs/http-contract.md | 71 ++++++++++++++++++++++++++++ packages/dewee-webchat/README.md | 79 +++++++++++++++++++++++++++++++- 4 files changed, 169 insertions(+), 2 deletions(-) diff --git a/.env.example b/.env.example index 1d9039a..86dc102 100644 --- a/.env.example +++ b/.env.example @@ -17,3 +17,8 @@ DEWEE_CLIENT_CREDENTIAL_HOSTS= # Optional Cloudflare Turnstile bot check TURNSTILE_SITE_KEY= TURNSTILE_SECRET_KEY= +# Optional: signed-in users through Dewee (read in your code and passed as the handler's `identity` +# option together with your authenticate(req) callback). From `dewee identity setup`. Server-side only. +DEWEE_IDENTITY_APP_ID= +DEWEE_IDENTITY_ISSUER= +DEWEE_IDENTITY_ASSERTION_SECRET= diff --git a/README.md b/README.md index 27857dd..dba2b69 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,15 @@ npm i dewee-webchat Full options, framework snippets and the security model: [packages/dewee-webchat/README.md](packages/dewee-webchat/README.md). +## Signed-in users + +If your site knows who the visitor is, pass one server-side callback (`identity.authenticate`) to the +handler. It exchanges your user for a short-lived Dewee identity session, and the agent then chats as +that verified user, with private tools scoped to them. You no longer mint visitor tokens, push +per-visitor credentials or build your own account linking: a signed-in user links Telegram or Discord +from the widget with a one-time code. Anonymous visitors work exactly as before. Setup and the +fail-closed rules: [Signed-in users](packages/dewee-webchat/README.md#signed-in-users). + ## Live examples All of them run on Cloudflare and share the same scoped demo key. @@ -51,6 +60,7 @@ All of them run on Cloudflare and share the same scoped demo key. | [`cdn/`](cdn) | Worker that serves versioned bundles at `webchat-cdn.dewee.sh` | | [`docs/http-contract.md`](docs/http-contract.md) | Wire format between the widget and any backend | | [`examples/`](examples) | Runnable integrations | +| [`vendor/`](vendor) | Temporary: the packed `@dewee/identity` SDK used by the tests (see below) | ## Development @@ -61,6 +71,12 @@ pnpm typecheck pnpm build # packages/dewee-webchat/dist ``` +`@dewee/identity` (the Dewee identity server SDK) is not published to npm yet. Until it is, the tests use a +tarball packed from the dewee repository (`packages/dewee-identity`, `pnpm --filter @dewee/identity pack`) and +committed as `vendor/dewee-identity-.tgz`; `packages/dewee-webchat` depends on it as a dev dependency +through `file:`. This is temporary: once the SDK is published, the dev dependency switches to the npm version and +`vendor/` is deleted. For users the SDK is an optional peer dependency, needed only with the `identity` option. + Run the playground locally: copy `examples/cloudflare/.dev.vars.example` to `.dev.vars`, fill it in, then `pnpm -C examples/cloudflare dev`. diff --git a/docs/http-contract.md b/docs/http-contract.md index e528b6f..2c4d8ab 100644 --- a/docs/http-contract.md +++ b/docs/http-contract.md @@ -5,6 +5,7 @@ implements these four routes works with the widget — the Node/Workers handler `dewee-webchat/server` and the single-file PHP backend in `php/` both do. Errors always use `{"error": {"message": string, "code": string}}` with a matching HTTP status. +Identity errors of [signed-in users](#signed-in-users) add `remediation`. ## `GET {base}/config` @@ -114,6 +115,74 @@ messages are ordinary chat messages, so rate limits and `maxMessageLength` apply **Errors** (JSON, before streaming): `400 invalid_actions`, `413 actions_too_large`, `413 context_too_large`. +## Signed-in users + +Optional. A backend that signs users in through Dewee (the Node/Workers handler with the `identity` +option; not the PHP backend yet) changes the routes below. Without it nothing here applies and every +request and response is exactly as described above. + +**`GET {base}/config`** adds `"identity": true`, sets `allowAgentSwitch` to `false` and `defaultAgent` +to the agent sessions are bound to. The widget then asks `/session` on every page load and keeps a +signed-in session in memory only. + +**`POST {base}/session`** first asks the host who the visitor is (server side, from its own login): + +- Signed in: the backend exchanges the user for a Dewee identity session + (`POST {gateway}/v1/identity/sessions/exchange` with a signed partner assertion) and answers + `{ "visitorId", "token", "expiresAt", "verified": true }`. The visitor token carries two more claims, + `ids` (the Dewee identity session token: one agent, chat only, short-lived) and `ida` (its agent): + `base64url(JSON{"v", "exp", "ids", "ida"})`. `exp` is the earlier of the identity session expiry and + the visitor session lifetime. `visitorId` is derived from the Dewee principal, so the same user keeps + the same conversations everywhere. If the exchange fails, no token is issued. +- Not signed in: an ordinary anonymous visitor token, unless the backend requires sign-in, in which case + the answer is `401 IDENTITY_REQUIRED` (or `503 SOURCE_UNAVAILABLE` when the host's login check failed). + +The gateway token, the partner assertion secret and the private key never appear in any response. + +**`POST {base}/chat`, `GET {base}/history`** with a token that carries `ids`: the backend connects with +`{"token": …, "identity_session": , "tenant_id"?: …}` and no `user_id`; the gateway derives the user +(`tuc:`). The request is bound to the token's user whatever cookies it carries, and only to +the `ida` agent (another `agent` is `403 agent_not_allowed`). The connection is checked before the stream +starts, so a refused session is a JSON error, not an SSE event. A token with `ids` is refused (401) by a +backend that does not sign users in. + +**Identity errors.** The gateway's typed errors are forwarded with their remediation: + +```json +{ "error": { "message": "Your session expired.", "code": "LINK_EXPIRED", + "remediation": { "action": "reauthenticate", "audience": "end_user" } } } +``` + +| `code` | Status | | +|---|---|---| +| `IDENTITY_REQUIRED` | 401 | No valid identity (not signed in, unknown session) | +| `LINK_EXPIRED` | 401 | The identity session expired; the widget asks `/session` again | +| `LINK_REVOKED` | 401 | Access was revoked or the user suspended; the widget asks `/session` again | +| `IDENTITY_LINK_REQUIRED` | 403 | The chat account is not linked yet | +| `PERMISSION_DENIED` | 403 | Verified but not allowed (for example another agent) | +| `IDENTITY_CONFLICT` | 409 | The account is linked to another user | +| `SOURCE_UNAVAILABLE` | 503 | Identity could not be checked; nothing ran | + +An identity error raised during a run arrives as the SSE event +`{"type": "error", "message", "code", "remediation"}`. The widget shows its own localized text for +each code and, after `LINK_EXPIRED` or `LINK_REVOKED`, requests `/session` again. Other exchange +failures are `502 gateway_error`, `429 rate_limited` or `500 identity_misconfigured`, without detail. + +### `POST {base}/link` + +Web-first linking of a chat account for a signed-in user. Headers: `Authorization: Bearer `. Body: `{ "idempotencyKey"?: string }` (1 to 255 printable ASCII characters). The +backend calls `POST {gateway}/v1/identity/links` with the identity session and answers: + +```json +{ "proofCode": "482913", "expiresAt": 1791621449203, + "deepLinks": [{ "channelType": "telegram", "channelInstanceId": "…", "name": "acme-bot", "displayName": "Acme Bot" }] } +``` + +The user sends `proofCode` in a direct message from the chat account to link; Dewee checks both sides +and links on its own. Nobody types an account id. A visitor without an identity session gets +`401 IDENTITY_REQUIRED`; a backend that does not sign users in answers `404`. + ## `GET {base}/history?agent=…&conversationId=…` Headers: `Authorization: Bearer `. Returns the visitor's own conversation: @@ -138,6 +207,8 @@ refused unless explicitly allowlisted. The backend opens a WebSocket to `{gateway}/ws` (protocol v3): 1. `{"type":"req","id":"1","method":"connect","params":{"token":…,"user_id":"webchat-","tenant_id"?:…}}` + (for a signed-in user: `"identity_session": …` instead of `user_id`; a refused session answers + `{"ok":false,"error":{"code":"LINK_EXPIRED",…,"details":{"remediation":{…}}}}`) 2. `{"type":"req","id":"2","method":"chat.send","params":{"agentId":…,"sessionKey":…,"message":…,"stream":true}}` 3. Gateway pushes `{"type":"event","event":"agent","payload":{"type":…,"sessionKey":…,"payload":{…}}}` with `type` ∈ `run.started`, `thinking {content}`, `chunk {content}`, `tool.call {id,name,arguments}`, diff --git a/packages/dewee-webchat/README.md b/packages/dewee-webchat/README.md index b3b256e..3655a0b 100644 --- a/packages/dewee-webchat/README.md +++ b/packages/dewee-webchat/README.md @@ -129,8 +129,9 @@ Attributes use kebab-case (`show-reasoning="false"`), `data-*` on the script tag | `actionTimeoutMs` | `30000` | Per-handler timeout | Methods: `open()`, `close()`, `toggle()`, `send(text)`, `stop()`, `newConversation()`, `configure(partial)`, -`registerActions(actions)`, `unregisterAction(name)`. -Events: `dewee:ready`, `dewee:open`, `dewee:close`, `dewee:send`, `dewee:reply`, `dewee:error`, `dewee:action`. +`registerActions(actions)`, `unregisterAction(name)`, and for [signed-in users](#signed-in-users) +`renewSession()` and `startLink()`. +Events: `dewee:ready`, `dewee:open`, `dewee:close`, `dewee:send`, `dewee:reply`, `dewee:error`, `dewee:action`, `dewee:link`. CSS variables: `--dw-accent`, `--dw-font`, `--dw-radius`, `--dw-width`, `--dw-height`, `--dw-z`. ## Agent page actions @@ -195,10 +196,84 @@ or inserts it as HTML, caps calls per answer and automatic turns per message, an `mutate`/`destructive` handler without approval. Page context and results are labelled as data for the agent, but prompt injection through page content is still possible — do not put secrets in either. +## Signed-in users + +When your site knows who the visitor is, the agent can know it too, with private tools that act for +that user only. You pass one server-side callback. You do not mint tokens, store per-visitor +credentials or put anything secret in the page: the handler exchanges your user for a Dewee identity +session, and the gateway derives the user from it. + +```ts +import { createDeweeHandler, optionsFromEnv } from "dewee-webchat/server"; + +const handler = createDeweeHandler({ + ...optionsFromEnv(process.env), + identity: { + // Your own login. Return null for a visitor who is not signed in. + authenticate: async (req) => { + const user = await getUserFromSessionCookie(req.headers.get("cookie")); + return user ? { subject: user.id } : null; // your stable id, never an email address + }, + appId: process.env.DEWEE_IDENTITY_APP_ID!, + issuer: process.env.DEWEE_IDENTITY_ISSUER!, + assertionSecret: process.env.DEWEE_IDENTITY_ASSERTION_SECRET!, // or privateKeyJwk for a jwks application + }, +}); +``` + +Setup: create the application once with `dewee identity setup --name --issuer ` (it +prints the application id and the assertion secret), and install the server SDK next to this package: +`npm install @dewee/identity`. It is an optional peer dependency, loaded only when `identity` is set. +`@dewee/identity` is not on npm yet: until it is, build its tarball from the +[dewee repository](https://github.com/nextlevelbuilder/dewee/blob/dev/packages/dewee-identity/README.md) +(`pnpm --filter @dewee/identity pack`) and install that file. + +| `identity` option | | +|---|---| +| `authenticate(req)` | Required. Resolves `{ subject }` for a signed-in user, `null` otherwise. | +| `appId`, `issuer` | Required. The Dewee application id and its issuer, exactly. | +| `assertionSecret` \| `privateKeyJwk` (+ `algorithm`) | Exactly one: `hmac` or `jwks` application. Server only. | +| `audience` | Optional check; must be `dewee:`. | +| `agent` | The agent sessions are bound to. Default: `defaultAgent`, else the first of `agents`. | +| `required` | Default `false`. When `true`, visitors who are not signed in get no session at all. | +| `sessionTtlSeconds` | 60 to 43200. Default: the gateway default (3600). | +| `allowInsecureHttp` | Allow plain http to a gateway that is not loopback (private networks only). | + +How it behaves: + +- `POST /session` calls `authenticate`. For a user it calls `exchangeSession` and returns a visitor token + that carries the Dewee session (one agent, chat only, short-lived; it expires with the session). The + gateway token, the assertion secret and the private key never reach the browser. +- `/chat` and `/history` connect to the gateway with that `identity_session` and no `user_id`. A + visitor token for user A keeps chatting as A even if a later request carries B's cookie; a token + that was altered is refused (401). The same user gets the same conversations on every device. +- Fail closed. If `authenticate` returns `null` or throws, the visitor chats anonymously, exactly as + without `identity`, unless `required` is set, in which case no session is issued. If the exchange + for a signed-in user fails, no session is issued: a user is never silently downgraded. +- `DEWEE_GATEWAY_TOKEN` must be a tenant API key without an owner: the gateway refuses an identity + session next to a key bound to a user (`IDENTITY_CONFLICT`). +- `identity` cannot be combined with `allowClientCredentials` (the handler refuses to start). Sessions + are bound to one agent, so `/config` turns the agent picker off. +- Gateway identity errors (`IDENTITY_REQUIRED`, `IDENTITY_LINK_REQUIRED`, `LINK_EXPIRED`, `LINK_REVOKED`, + `PERMISSION_DENIED`, `SOURCE_UNAVAILABLE`, `IDENTITY_CONFLICT`) reach the widget as + `{ error: { code, remediation } }`. The widget shows its own localized message and, on `LINK_EXPIRED` + or `LINK_REVOKED`, asks `/session` again. +- The widget keeps a signed-in session in memory only, so each page load asks your backend who the + visitor is. Call `chat.renewSession()` after a sign-in or sign-out without a page load. + +**Linking a chat app.** A signed-in user can link their Telegram, Discord or other chat account +without typing any id: call `chat.startLink()`. The widget asks `POST {endpoint}/link`, which starts +a Dewee web-first link, and shows the proof code with "Continue on Telegram" for each chat account +the code can be sent to. The user sends the code in a direct message; Dewee checks both sides and +links on its own. `dewee:link` fires with `{ expiresAt, deepLinks }`. + +The PHP backend does not support signed-in users yet (it has no JWS signing); use the Node/Workers handler. + ## Security model - The gateway token never leaves your server. Browsers get an HMAC-signed visitor id. - Each visitor gets its own gateway user (`webchat-`) and session keys, so history is private to them. + A [signed-in user](#signed-in-users) is the user the gateway derives from their Dewee identity session. - Only allowlisted agents are reachable; origin allowlist, rate limits (20 messages/min per visitor, 10 new sessions/10 min per IP by default — pass `rateLimit` for a shared store), 4000-char messages, optional Turnstile. From 84d2ba1ffb27484fddb2df0cea177c4db5f93718 Mon Sep 17 00:00:00 2001 From: Duy Nguyen Date: Tue, 20 Oct 2026 17:48:44 +0700 Subject: [PATCH 4/7] fix(server): fail closed on unapplied identity sessions and harden signed-in sessions - Refuse a connect whose answer does not name a tuc: user, so a gateway that ignored identity_session never runs the turn as the operator token's user (503 SOURCE_UNAVAILABLE). - Move the @dewee/identity calls to a separate dewee-webchat/server/identity entry passed as identity.sdk; dewee-webchat/server no longer references the SDK, so anonymous-only hosts bundle it with webpack without the SDK. - Seal the identity session in the visitor token with AES-256-GCM (HKDF key from the handler secret, bound to visitor and agent). - Reuse one identity session per user until it nears expiry, drop it when the gateway reports it dead, and measure its lifetime from receipt; return expiresIn so the widget refreshes min(60s, lifetime/2) before the end. - Rate-limit signed-in sessions per user and links in their own bucket; hand a valid anonymous token back without minting. - Add opt-in rebindEachRequest: chat, history and link re-run authenticate and compare a sealed subject digest. - A throwing authenticate is 503, exchange refusals for operators are 500 identity_misconfigured, link conflicts get their own codes, the bound agent is checked against the current allowlist, and scopes is removed from AuthenticatedUser. - The widget renews a refused signed-in session once for link and history. Co-Authored-By: Claude Opus 5.5 --- packages/dewee-webchat/src/protocol.ts | 17 +- .../src/server/gateway-client.ts | 12 +- packages/dewee-webchat/src/server/handler.ts | 367 +++++++++++++----- packages/dewee-webchat/src/server/identity.ts | 13 + packages/dewee-webchat/src/server/index.ts | 12 +- packages/dewee-webchat/src/server/security.ts | 102 ++++- .../dewee-webchat/src/widget/api-client.ts | 43 +- packages/dewee-webchat/src/widget/element.ts | 5 +- packages/dewee-webchat/src/widget/i18n.ts | 14 +- packages/dewee-webchat/test/identity.test.ts | 366 +++++++++++++---- packages/dewee-webchat/tsup.config.ts | 4 +- 11 files changed, 775 insertions(+), 180 deletions(-) create mode 100644 packages/dewee-webchat/src/server/identity.ts diff --git a/packages/dewee-webchat/src/protocol.ts b/packages/dewee-webchat/src/protocol.ts index f471693..eb9f4a1 100644 --- a/packages/dewee-webchat/src/protocol.ts +++ b/packages/dewee-webchat/src/protocol.ts @@ -54,7 +54,7 @@ export interface PublicConfig { turnstileSiteKey?: string; maxMessageLength: number; version: string; - /** Present (true) when the backend signs users in through Dewee: the widget then asks for a fresh session on every page load. */ + /** Present (true) when the backend signs users in through Dewee: the widget then asks `/session` on every page load. */ identity?: boolean; } @@ -65,6 +65,21 @@ export interface SessionResponse { expiresAt: number; /** Present (true) when the session carries a verified Dewee identity. Such sessions are short-lived and never persisted. */ verified?: boolean; + /** + * Verified sessions only: milliseconds the session stays valid, counted from when the response is + * received. The widget refreshes from this rather than `expiresAt`, so a skewed browser clock does not + * matter. + */ + expiresIn?: number; +} + +/** Error codes of `POST {basePath}/link` besides the identity codes. */ +export const LINK_ERROR_CODES = ["idempotency_key_reused", "link_in_progress", "linking_unavailable"] as const; + +export type LinkErrorCode = (typeof LINK_ERROR_CODES)[number]; + +export function isLinkErrorCode(code: unknown): code is LinkErrorCode { + return typeof code === "string" && (LINK_ERROR_CODES as readonly string[]).includes(code); } /** A chat account the proof code of a web-first link can be sent to. */ diff --git a/packages/dewee-webchat/src/server/gateway-client.ts b/packages/dewee-webchat/src/server/gateway-client.ts index 45fee1f..11d4c09 100644 --- a/packages/dewee-webchat/src/server/gateway-client.ts +++ b/packages/dewee-webchat/src/server/gateway-client.ts @@ -46,6 +46,11 @@ function parseRemediation(raw: unknown): Remediation | undefined { return typeof action === "string" && typeof audience === "string" ? { action, audience } : undefined; } +/** A user the gateway derived from an identity session: `tuc:`. */ +function isIdentityUser(userId: unknown): boolean { + return typeof userId === "string" && userId.startsWith("tuc:") && userId.length > 4; +} + function wsUrl(base: string): string { const u = new URL(base); u.protocol = u.protocol === "http:" ? "ws:" : "wss:"; @@ -93,7 +98,12 @@ export class GatewayConnection { typeof actor === "string" ? { token: creds.token, user_id: actor } : { token: creds.token, identity_session: actor.identitySession }; if (creds.tenantId) params.tenant_id = creds.tenantId; try { - await conn.call("connect", params, timeoutMs); + const res = await conn.call<{ user_id?: unknown } | undefined>("connect", params, timeoutMs); + // Fail closed: a gateway that did not apply the identity session (identity switched off, or a + // replica that failed to start it) would otherwise run the turn as the operator token's own user. + if (typeof actor !== "string" && !isIdentityUser(res?.user_id)) { + throw new GatewayError("Gateway did not apply the identity session", "SOURCE_UNAVAILABLE", { action: "retry_later", audience: "operator" }); + } } catch (e) { conn.close(); throw e; diff --git a/packages/dewee-webchat/src/server/handler.ts b/packages/dewee-webchat/src/server/handler.ts index 1d19a3b..ae945c9 100644 --- a/packages/dewee-webchat/src/server/handler.ts +++ b/packages/dewee-webchat/src/server/handler.ts @@ -14,8 +14,8 @@ import { type Remediation, type SessionResponse, } from "../protocol"; -// Type-only: erased at build time. The SDK itself is loaded lazily, in identity mode only. -import type { AssertionSigner } from "@dewee/identity"; +// This module never imports `@dewee/identity`, not even lazily, so bundlers of anonymous-only hosts never +// look for it. Identity mode gets the SDK through `identity.sdk` (from `dewee-webchat/server/identity`). import { ActionInputError, buildPageBlock, hashManifest, normalizeManifest, serializePageContext } from "../actions/page-block"; import { GatewayConnection, @@ -32,21 +32,21 @@ import { memoryRateLimiter, randomId, signVisitorToken, + subjectDigest, verifyTurnstile, verifyVisitorToken, type RateLimiter, type VisitorClaims, } from "./security"; -/** A signed-in user of the host app, as `identity.authenticate` reports it. */ +/** + * A signed-in user of the host app, as `identity.authenticate` reports it. Scopes are not part of + * the session exchange: report scope changes with `lifecycle({ event: "scopes_changed" })` from + * `@dewee/identity`. + */ export interface AuthenticatedUser { /** Your stable, opaque user id. Never an email address (an id containing "@" is refused). */ subject: string; - /** - * Reserved. The Dewee session exchange carries no scopes, so this value is not sent anywhere. - * Report scope changes with `lifecycle({ event: "scopes_changed" })` from `@dewee/identity`. - */ - scopes?: string[]; } /** A private JWK whose public half is published in the application's `jwks_url`. */ @@ -58,13 +58,46 @@ export interface IdentityPrivateJwk { [key: string]: unknown; } +/** Partner assertion signer, the shape `@dewee/identity` takes. */ +export type IdentitySigner = + | { applicationId: string; issuer: string; secret: string } + | { applicationId: string; issuer: string; privateKeyJwk: IdentityPrivateJwk; algorithm?: "RS256" | "ES256" | "EdDSA" }; + +/** + * The two `@dewee/identity` calls the handler uses. Pass `deweeIdentitySdk` from + * `dewee-webchat/server/identity`; it is a separate entry so that only hosts that sign users in + * need the SDK installed and bundled. + */ +export interface DeweeIdentitySdk { + exchangeSession(opts: { + gatewayUrl: string; + allowInsecureHttp?: boolean; + signer: IdentitySigner; + subject: string; + agent: string; + ttlSeconds?: number; + }): Promise<{ sessionToken: string; principalId: string; expiresAt: Date }>; + startLink(opts: { + gatewayUrl: string; + allowInsecureHttp?: boolean; + sessionToken: string; + idempotencyKey?: string; + }): Promise<{ proofCode: string; expiresAt: Date; deepLinks: Array<{ channelType: string; channelInstanceId: string; name: string; displayName?: string }> }>; +} + /** * Verified signed-in users through a Dewee identity session exchange. The host passes one - * server-side callback; the handler exchanges the user for a limited Dewee session (one agent, - * chat only, short-lived) and the gateway derives the user from it. + * server-side callback; the handler exchanges the user for a short-lived Dewee identity session bound + * to one agent, and the gateway derives the user from it. */ export interface DeweeIdentityOptions { - /** Reads your own login (cookie, header…). Return null for a visitor who is not signed in. */ + /** `deweeIdentitySdk` from `dewee-webchat/server/identity`. */ + sdk: DeweeIdentitySdk; + /** + * Reads your own login (cookie, header…). Return null for a visitor who is not signed in. Throwing + * means the login could not be read: the visitor gets `503 SOURCE_UNAVAILABLE`, never an anonymous + * session. + */ authenticate(req: Request): Promise; /** Dewee application id (`dewee identity setup` prints it). */ appId: string; @@ -82,9 +115,17 @@ export interface DeweeIdentityOptions { agent?: string; /** * Refuse visitors who are not signed in (no anonymous fallback). Default false: a visitor for whom - * `authenticate` returns null, or throws, chats anonymously exactly as without this option. + * `authenticate` returns null chats anonymously exactly as without this option. */ required?: boolean; + /** + * Check the signed-in user again on every `/chat`, `/history` and `/link`: `authenticate(req)` must + * return the same subject the session was issued for, else the request is `401 IDENTITY_REQUIRED` + * (the widget then asks `/session` again); if it throws, `503 SOURCE_UNAVAILABLE`. Recommended when + * your login is a cookie: a widget token replayed under another user's cookie is then useless. + * Default false. + */ + rebindEachRequest?: boolean; /** Identity session lifetime, 60 to 43200 seconds. Default: the gateway default (3600). */ sessionTtlSeconds?: number; /** Allow plain http to a gateway that is not loopback (private networks only). */ @@ -113,7 +154,11 @@ export interface DeweeHandlerOptions { allowClientCredentials?: boolean | { allowedHosts?: string[] }; /** Cloudflare Turnstile. When set, issuing a visitor session requires a valid challenge. */ turnstile?: { siteKey: string; secretKey: string }; - /** Custom limiter, or false to disable. Default: in-memory 20 msgs/min, 10 sessions/10 min per IP. */ + /** + * Custom limiter, or false to disable. Default (in memory): 20 messages/min per visitor and IP, + * 10 new sessions/10 min per IP and, with `identity`, 30 signed-in sessions/10 min per user and + * 5 link starts/10 min per user. + */ rateLimit?: RateLimiter | false; maxMessageLength?: number; /** Prefix for the per-visitor gateway user id. */ @@ -148,16 +193,15 @@ const IDENTITY_ERRORS: Record | undefined; - -/** Loads `@dewee/identity` on first use, so anonymous-only deployments never need it installed. */ -function loadIdentitySdk(): Promise { - return (identitySdk ??= import("@dewee/identity").catch((e: unknown) => { - identitySdk = undefined; - throw new Error(`identity mode needs the @dewee/identity package (${(e as Error)?.message ?? "not installed"})`); - })); -} +/** Identity errors after which a cached identity session is dropped. */ +const DEAD_SESSION_CODES = new Set(["IDENTITY_REQUIRED", "LINK_EXPIRED", "LINK_REVOKED"]); +/** Gateway default identity session lifetime, used when the expiry cannot be trusted (clock skew). */ +const DEFAULT_IDENTITY_TTL_MS = 3600_000; +const MAX_IDENTITY_TTL_MS = 43_200_000; +/** Most identity sessions kept for reuse; each is a few hundred bytes. */ +const MAX_CACHED_SESSIONS = 1000; +/** An anonymous token this far from expiry is handed back as is instead of minting a new one. */ +const ANON_REUSE_MS = 86_400_000; /** Validates the identity options once, at startup, and returns the agent sessions are bound to. */ function checkIdentityOptions(o: DeweeHandlerOptions, staticAgents: AgentInfo[]): string { @@ -167,6 +211,9 @@ function checkIdentityOptions(o: DeweeHandlerOptions, staticAgents: AgentInfo[]) }; if (o.allowClientCredentials) refuse("cannot be combined with allowClientCredentials (a visitor-chosen gateway must never receive a user's session)"); if (!o.gatewayUrl || !o.token) refuse("needs the server's own gatewayUrl and token"); + if (!idn.sdk || typeof idn.sdk.exchangeSession !== "function" || typeof idn.sdk.startLink !== "function") { + refuse('sdk is required: import { deweeIdentitySdk } from "dewee-webchat/server/identity" (needs the @dewee/identity package)'); + } if (typeof idn.authenticate !== "function") refuse("authenticate(req) is required"); if (typeof idn.appId !== "string" || !UUID_RE.test(idn.appId)) refuse("appId must be the Dewee application id (a UUID)"); if (typeof idn.issuer !== "string" || idn.issuer.trim() === "") refuse("issuer is required"); @@ -198,7 +245,9 @@ export function createDeweeHandler(options: DeweeHandlerOptions): Handler { console.warn("[dewee-webchat] No secret configured: using an ephemeral one. Visitor sessions reset on restart."); } const limiter: RateLimiter | null = - o.rateLimit === false ? null : o.rateLimit ?? memoryRateLimiter({ chat: [20, 60_000], session: [10, 600_000] }); + o.rateLimit === false + ? null + : o.rateLimit ?? memoryRateLimiter({ chat: [20, 60_000], session: [10, 600_000], verified: [30, 600_000], link: [5, 600_000] }); const staticAgents: AgentInfo[] = o.agents === "*" ? [] : (o.agents ?? []).map((a) => (typeof a === "string" ? { id: a } : a)); const wildcardAgents = o.agents === "*"; @@ -275,9 +324,18 @@ export function createDeweeHandler(options: DeweeHandlerOptions): Handler { return json(req, { error: { message: e.message, code, remediation: remediation ?? { action: e.action, audience: "end_user" } } }, e.status); }; - /** Gateway WebSocket failure: identity codes are forwarded typed, anything else stays a gateway error. */ - const gatewayFail = (req: Request, e: unknown) => - e instanceof GatewayError && isIdentityErrorCode(e.code) ? identityFail(req, e.code, e.remediation) : fail(req, 502, (e as Error).message, "gateway_error"); + const misconfigured = (req: Request) => fail(req, 500, "Signed-in chat is misconfigured on this server", "identity_misconfigured"); + + /** + * Gateway WebSocket failure: identity codes are forwarded typed, anything else stays a gateway error. + * A dead identity session is dropped from the reuse cache. + */ + const gatewayFail = (req: Request, e: unknown, sessionToken?: string) => { + if (!(e instanceof GatewayError && isIdentityErrorCode(e.code))) return fail(req, 502, (e as Error).message, "gateway_error"); + if (sessionToken && DEAD_SESSION_CODES.has(e.code)) forgetCachedSession(sessionToken); + if (e.remediation?.audience === "operator") console.error(`[dewee-webchat] identity: ${e.message} (${e.code})`); + return identityFail(req, e.code, e.remediation); + }; /** `@dewee/identity` failure (exchange, link): typed identity codes are forwarded; SDK messages are not. */ const sdkFail = (req: Request, e: unknown, what: string) => { @@ -288,10 +346,37 @@ export function createDeweeHandler(options: DeweeHandlerOptions): Handler { } console.warn(`[dewee-webchat] identity ${what} failed:`, code ?? e); if (code === "RESOURCE_EXHAUSTED") return fail(req, 429, "Too many sign-ins, slow down", "rate_limited"); - if (code === "INVALID_ARGUMENT" || code === "SERVER_ONLY") return fail(req, 500, "Signed-in chat is misconfigured on this server", "identity_misconfigured"); + if (code === "INVALID_ARGUMENT" || code === "SERVER_ONLY") return misconfigured(req); return fail(req, 502, "Dewee is unavailable, try again later", "gateway_error"); }; + /** + * Exchange failure. Only an error the signed-in user can act on reaches the browser typed; one + * addressed to the operator or integrator (an application or agent set up wrong, exchange switched + * off on the gateway) is this server's misconfiguration. + */ + const exchangeFail = (req: Request, e: unknown) => { + const { code, remediation } = (e ?? {}) as { code?: unknown; remediation?: Remediation }; + if ((isIdentityErrorCode(code) && remediation && remediation.audience !== "end_user") || code === "NOT_FOUND") { + console.error(`[dewee-webchat] identity session exchange refused: ${String(code)} (remediation for ${remediation?.audience ?? "operator"}: ${remediation?.action ?? "check the gateway"})`); + return misconfigured(req); + } + return sdkFail(req, e, "session exchange"); + }; + + /** Link start failure: the gateway's conflict and availability codes get their own answers. */ + const linkFail = (req: Request, e: unknown, sessionToken: string) => { + const code = (e as { code?: unknown })?.code; + if (code === "IDEMPOTENCY_KEY_REUSED") return fail(req, 409, "This idempotency key was already used for a different link request", "idempotency_key_reused"); + if (code === "FAILED_PRECONDITION") return fail(req, 409, "A link is already in progress. Use its code or wait until it expires.", "link_in_progress"); + if (code === "NOT_FOUND") { + console.error("[dewee-webchat] identity link start: the gateway has identity exchange switched off"); + return fail(req, 503, "Linking is not available right now", "linking_unavailable"); + } + if (typeof code === "string" && DEAD_SESSION_CODES.has(code)) forgetCachedSession(sessionToken); + return sdkFail(req, e, "link start"); + }; + const clientIp = (req: Request) => req.headers.get("cf-connecting-ip") || req.headers.get("x-real-ip") || req.headers.get("x-forwarded-for")?.split(",")[0].trim() || "unknown"; @@ -304,47 +389,114 @@ export function createDeweeHandler(options: DeweeHandlerOptions): Handler { return claims; } - /** Agent of a request: an identity session is bound to its agent; anything else follows the allowlist. */ + /** + * Agent of a request: an identity session is bound to its agent, which must still be allowed by the + * current `agents`; anything else follows the allowlist. + */ async function agentFor(claims: VisitorClaims, requested: string | null | undefined, creds: GatewayCredentials | null, isByo: boolean): Promise { - if (claims.ids) return !requested || requested === claims.ida ? claims.ida! : null; + if (claims.ids) { + const bound = claims.ida!; + if (requested && requested !== bound) return null; + return wildcardAgents || staticAgents.some((a) => a.id === bound) ? bound : null; + } return pickAgent(requested, creds, isByo); } + /** + * With `rebindEachRequest`: the request's own login must still be the user the token was issued for. + * Returns the refusal, or null to go on. + */ + async function rebindRefusal(req: Request, claims: VisitorClaims): Promise { + if (!idn?.rebindEachRequest || !claims.ids) return null; + let user: AuthenticatedUser | null; + try { + user = await idn.authenticate(req); + } catch (e) { + console.warn("[dewee-webchat] identity.authenticate failed:", e); + return identityFail(req, "SOURCE_UNAVAILABLE", { action: "retry_later", audience: "end_user" }); + } + const same = !!user && typeof user.subject === "string" && !!claims.sub && (await subjectDigest(secret!, user.subject)) === claims.sub; + return same ? null : identityFail(req, "IDENTITY_REQUIRED"); + } + const actorOf = (claims: VisitorClaims): GatewayActor => (claims.ids ? { identitySession: claims.ids } : o.userIdPrefix + claims.v); - function identitySigner(): AssertionSigner { + function identitySigner(): IdentitySigner { if (idn!.assertionSecret) return { applicationId: idn!.appId, issuer: idn!.issuer, secret: idn!.assertionSecret }; - // IdentityPrivateJwk is the JSON shape of jose's JWK, which the SDK types the key as. - return { applicationId: idn!.appId, issuer: idn!.issuer, privateKeyJwk: idn!.privateKeyJwk as AssertionSigner["privateKeyJwk"], algorithm: idn!.algorithm }; + return { applicationId: idn!.appId, issuer: idn!.issuer, privateKeyJwk: idn!.privateKeyJwk!, algorithm: idn!.algorithm }; } - /** Exchanges a signed-in user for a Dewee identity session and wraps it in a visitor token. */ - async function issueIdentitySession(req: Request, subject: string): Promise { - let sdk: IdentitySdk; - try { - sdk = await loadIdentitySdk(); - } catch (e) { - console.error("[dewee-webchat]", (e as Error).message); - return fail(req, 500, "Signed-in chat is misconfigured on this server", "identity_misconfigured"); + /** + * One identity session per user (and bound agent), reused until it nears expiry, so a page load does + * not cost an exchange. Bounded; a session the gateway reports dead is dropped. + */ + interface CachedSession { sessionToken: string; visitorId: string; deadline: number; margin: number } + const sessionCache = new Map(); + const exchanges = new Map>(); + + function forgetCachedSession(sessionToken: string): void { + for (const [k, c] of sessionCache) if (c.sessionToken === sessionToken) sessionCache.delete(k); + } + + function rememberSession(key: string, c: CachedSession): void { + sessionCache.delete(key); + sessionCache.set(key, c); + if (sessionCache.size <= MAX_CACHED_SESSIONS) return; + const now = Date.now(); + for (const [k, v] of sessionCache) if (v.deadline - now <= v.margin) sessionCache.delete(k); + // Still full: drop the least recently issued. + for (const k of sessionCache.keys()) { + if (sessionCache.size <= MAX_CACHED_SESSIONS) break; + sessionCache.delete(k); } - let session: Awaited>; - try { - session = await sdk.exchangeSession({ - gatewayUrl: o.gatewayUrl!, - allowInsecureHttp: idn!.allowInsecureHttp, - signer: identitySigner(), - subject, - agent: identityAgent, - ttlSeconds: idn!.sessionTtlSeconds, - }); - } catch (e) { - return sdkFail(req, e, "session exchange"); + } + + async function exchangeFor(subject: string): Promise { + const session = await idn!.sdk.exchangeSession({ + gatewayUrl: o.gatewayUrl!, + allowInsecureHttp: idn!.allowInsecureHttp, + signer: identitySigner(), + subject, + agent: identityAgent, + ttlSeconds: idn!.sessionTtlSeconds, + }); + // Lifetime is measured from receipt on this server's clock. The gateway's absolute expiry is only + // trusted while it fits the requested lifetime; otherwise the clocks disagree and the requested + // (or default) lifetime is used. + const receivedAt = Date.now(); + const bound = idn!.sessionTtlSeconds ? idn!.sessionTtlSeconds * 1000 : MAX_IDENTITY_TTL_MS; + let lifetime = session.expiresAt.getTime() - receivedAt; + if (!(lifetime > 0 && lifetime <= bound)) { + console.warn("[dewee-webchat] identity session expiry does not match this server's clock; using the requested lifetime"); + lifetime = idn!.sessionTtlSeconds ? idn!.sessionTtlSeconds * 1000 : DEFAULT_IDENTITY_TTL_MS; } // Same principal, same visitor id: conversations follow the user across devices and page loads. const visitorId = await deriveVisitorId(secret!, `principal:${session.principalId}`); - const expiresAt = Math.min(session.expiresAt.getTime(), Date.now() + o.sessionTtlMs); - const token = await signVisitorToken(secret!, { v: visitorId, exp: expiresAt, ids: session.sessionToken, ida: identityAgent }); - const body: SessionResponse = { visitorId, token, expiresAt, verified: true }; + return { sessionToken: session.sessionToken, visitorId, deadline: receivedAt + lifetime, margin: Math.min(60_000, lifetime / 2) }; + } + + /** A signed-in user's identity session, reused or exchanged, wrapped in a visitor token. */ + async function issueIdentitySession(req: Request, subject: string): Promise { + const key = `${identityAgent}\n${subject}`; + let s = sessionCache.get(key); + if (!s || s.deadline - Date.now() <= s.margin) { + let pending = exchanges.get(key); + if (!pending) { + pending = exchangeFor(subject).finally(() => exchanges.delete(key)); + exchanges.set(key, pending); + } + try { + s = await pending; + } catch (e) { + return exchangeFail(req, e); + } + rememberSession(key, s); + } + const now = Date.now(); + const expiresAt = Math.min(s.deadline, now + o.sessionTtlMs); + const sub = await subjectDigest(secret!, subject); + const token = await signVisitorToken(secret!, { v: s.visitorId, exp: expiresAt, ids: s.sessionToken, ida: identityAgent, sub }); + const body: SessionResponse = { visitorId: s.visitorId, token, expiresAt, verified: true, expiresIn: expiresAt - now }; return json(req, body); } @@ -385,35 +537,22 @@ export function createDeweeHandler(options: DeweeHandlerOptions): Handler { return json(req, body); } + /** Cloudflare Turnstile, when configured. */ + async function turnstilePassed(body: any, ip: string): Promise { + if (!o.turnstile) return true; + return !!body?.turnstileToken && (await verifyTurnstile(o.turnstile.secretKey, String(body.turnstileToken), ip)); + } + async function handleSession(req: Request): Promise { + if (idn) return handleIdentitySession(req); const ip = clientIp(req); if (limiter && !(await limiter(ip, "session"))) return fail(req, 429, "Too many sessions, slow down", "rate_limited"); const body: any = await req.json().catch(() => ({})); - if (o.turnstile) { - if (!body?.turnstileToken || !(await verifyTurnstile(o.turnstile.secretKey, String(body.turnstileToken), ip))) { - return fail(req, 403, "Verification failed", "turnstile_failed"); - } - } - if (idn) { - // Fail closed: a signed-in user gets an identity session or no session at all. Only a visitor - // who is not signed in (or whose login could not be read) falls back to anonymous, and only - // when identity is not required. - let user: AuthenticatedUser | null = null; - try { - user = await idn.authenticate(req); - } catch (e) { - console.warn("[dewee-webchat] identity.authenticate failed:", e); - if (idn.required) return identityFail(req, "SOURCE_UNAVAILABLE", { action: "retry_later", audience: "end_user" }); - } - if (user != null) { - if (typeof user !== "object" || typeof user.subject !== "string" || user.subject.trim() === "") { - console.error("[dewee-webchat] identity.authenticate must return { subject } or null"); - return fail(req, 500, "Signed-in chat is misconfigured on this server", "identity_misconfigured"); - } - return issueIdentitySession(req, user.subject); - } - if (idn.required) return identityFail(req, "IDENTITY_REQUIRED"); - } + return issueAnonymousSession(req, body, ip); + } + + async function issueAnonymousSession(req: Request, body: any, ip: string): Promise { + if (!(await turnstilePassed(body, ip))) return fail(req, 403, "Verification failed", "turnstile_failed"); // Refresh an existing visitor instead of minting a new identity, so history survives. // A signed-in user's token never seeds an anonymous visitor. const previous = typeof body?.token === "string" ? await verifyVisitorToken(secret!, body.token) : null; @@ -422,10 +561,49 @@ export function createDeweeHandler(options: DeweeHandlerOptions): Handler { return json(req, { visitorId, token: await signVisitorToken(secret!, { v: visitorId, exp: expiresAt }), expiresAt }); } + /** + * `/session` of a backend that signs users in. The widget calls it on every page load, so: + * a signed-in user is limited per user (not per IP) and reuses a cached identity session; a visitor + * who is not signed in gets a still-valid anonymous token back as is, and only minting a new one + * counts against the per-IP session limit. + */ + async function handleIdentitySession(req: Request): Promise { + const ip = clientIp(req); + const body: any = await req.json().catch(() => ({})); + // Fail closed: a signed-in user gets an identity session or no session at all. A login that could + // not be read is not "signed out", so it never falls back to anonymous. + let user: AuthenticatedUser | null; + try { + user = await idn!.authenticate(req); + } catch (e) { + console.warn("[dewee-webchat] identity.authenticate failed:", e); + return identityFail(req, "SOURCE_UNAVAILABLE", { action: "retry_later", audience: "end_user" }); + } + if (user != null) { + if (typeof user !== "object" || typeof user.subject !== "string" || user.subject.trim() === "") { + console.error("[dewee-webchat] identity.authenticate must return { subject } or null"); + return misconfigured(req); + } + if (limiter && !(await limiter(`subject:${user.subject}`, "verified"))) return fail(req, 429, "Too many sessions, slow down", "rate_limited"); + if (!(await turnstilePassed(body, ip))) return fail(req, 403, "Verification failed", "turnstile_failed"); + return issueIdentitySession(req, user.subject); + } + if (idn!.required) return identityFail(req, "IDENTITY_REQUIRED"); + const previous = typeof body?.token === "string" ? await verifyVisitorToken(secret!, body.token) : null; + if (previous && !previous.ids && previous.exp - Date.now() > ANON_REUSE_MS) { + const reused: SessionResponse = { visitorId: previous.v, token: body.token, expiresAt: previous.exp }; + return json(req, reused); + } + if (limiter && !(await limiter(ip, "session"))) return fail(req, 429, "Too many sessions, slow down", "rate_limited"); + return issueAnonymousSession(req, body, ip); + } + async function handleChat(req: Request): Promise { const claims = await visitorFrom(req); if (!claims) return fail(req, 401, "Missing or expired visitor session", "unauthorized"); if (idn?.required && !claims.ids) return identityFail(req, "IDENTITY_REQUIRED"); + const refused = await rebindRefusal(req, claims); + if (refused) return refused; const visitorId = claims.v; if (limiter && !(await limiter(`${visitorId}|${clientIp(req)}`, "chat"))) return fail(req, 429, "Too many messages, please wait a moment", "rate_limited"); const { creds, byo: isByo, error } = resolveCreds(req); @@ -462,7 +640,7 @@ export function createDeweeHandler(options: DeweeHandlerOptions): Handler { try { connection = await GatewayConnection.connect(creds, { identitySession: claims.ids }); } catch (e) { - return gatewayFail(req, e); + return gatewayFail(req, e, claims.ids); } } @@ -485,7 +663,10 @@ export function createDeweeHandler(options: DeweeHandlerOptions): Handler { for await (const ev of streamChat({ creds, ...who, agent, sessionKey, message: pageBlock + message, signal: abort.signal, timeoutMs: o.timeoutMs })) { if (ev.type === "start") continue; // already announced with the conversation id if (ev.type === "done") reply = ev.content; - if (ev.type === "error") errMsg = ev.message; + if (ev.type === "error") { + errMsg = ev.message; + if (claims.ids && ev.code && DEAD_SESSION_CODES.has(ev.code)) forgetCachedSession(claims.ids); + } await send(ev); if (abort.signal.aborted) break; } @@ -514,6 +695,8 @@ export function createDeweeHandler(options: DeweeHandlerOptions): Handler { const claims = await visitorFrom(req); if (!claims) return fail(req, 401, "Missing or expired visitor session", "unauthorized"); if (idn?.required && !claims.ids) return identityFail(req, "IDENTITY_REQUIRED"); + const refused = await rebindRefusal(req, claims); + if (refused) return refused; const visitorId = claims.v; const { creds, byo: isByo, error } = resolveCreds(req); if (error) return fail(req, 400, error, "bad_credentials"); @@ -529,29 +712,25 @@ export function createDeweeHandler(options: DeweeHandlerOptions): Handler { } catch (e) { // A conversation that has not sent its first message yet does not exist on the gateway. if ((e as GatewayError).code === "NOT_FOUND") return json(req, { messages: [] }); - return gatewayFail(req, e); + return gatewayFail(req, e, claims.ids); } } /** Web-first linking: starts a Dewee link for the signed-in user and returns the proof code. */ async function handleLink(req: Request): Promise { - if (limiter && !(await limiter(clientIp(req), "session"))) return fail(req, 429, "Too many requests, slow down", "rate_limited"); const claims = await visitorFrom(req); if (!claims?.ids) return identityFail(req, "IDENTITY_REQUIRED"); + const refused = await rebindRefusal(req, claims); + if (refused) return refused; + // Its own bucket, per user: linking never eats into the session budget, and the reverse. + if (limiter && !(await limiter(`visitor:${claims.v}`, "link"))) return fail(req, 429, "Too many link requests, slow down", "rate_limited"); const body: any = await req.json().catch(() => ({})); const idempotencyKey = body?.idempotencyKey; if (idempotencyKey !== undefined && (typeof idempotencyKey !== "string" || !IDEMPOTENCY_KEY_RE.test(idempotencyKey))) { return fail(req, 400, "idempotencyKey must be 1 to 255 printable ASCII characters without spaces"); } - let sdk: IdentitySdk; - try { - sdk = await loadIdentitySdk(); - } catch (e) { - console.error("[dewee-webchat]", (e as Error).message); - return fail(req, 500, "Signed-in chat is misconfigured on this server", "identity_misconfigured"); - } try { - const link = await sdk.startLink({ gatewayUrl: o.gatewayUrl!, allowInsecureHttp: idn!.allowInsecureHttp, sessionToken: claims.ids, idempotencyKey }); + const link = await idn!.sdk.startLink({ gatewayUrl: o.gatewayUrl!, allowInsecureHttp: idn!.allowInsecureHttp, sessionToken: claims.ids, idempotencyKey }); const res: LinkResponse = { proofCode: link.proofCode, expiresAt: link.expiresAt.getTime(), @@ -559,7 +738,7 @@ export function createDeweeHandler(options: DeweeHandlerOptions): Handler { }; return json(req, res); } catch (e) { - return sdkFail(req, e, "link start"); + return linkFail(req, e, claims.ids); } } diff --git a/packages/dewee-webchat/src/server/identity.ts b/packages/dewee-webchat/src/server/identity.ts new file mode 100644 index 0000000..dd7eb7f --- /dev/null +++ b/packages/dewee-webchat/src/server/identity.ts @@ -0,0 +1,13 @@ +// `dewee-webchat/server/identity`: the `@dewee/identity` calls the handler needs for signed-in users. +// A separate entry so that `dewee-webchat/server` never references the SDK and hosts that only serve +// anonymous visitors can bundle it without the SDK installed. +// +// import { createDeweeHandler } from "dewee-webchat/server"; +// import { deweeIdentitySdk } from "dewee-webchat/server/identity"; +// createDeweeHandler({ ..., identity: { sdk: deweeIdentitySdk, authenticate, appId, issuer, assertionSecret } }); +import { exchangeSession, startLink } from "@dewee/identity"; +import type { DeweeIdentitySdk } from "./handler"; + +export const deweeIdentitySdk: DeweeIdentitySdk = { exchangeSession, startLink }; + +export type { DeweeIdentitySdk } from "./handler"; diff --git a/packages/dewee-webchat/src/server/index.ts b/packages/dewee-webchat/src/server/index.ts index 7dd6317..bdda6a6 100644 --- a/packages/dewee-webchat/src/server/index.ts +++ b/packages/dewee-webchat/src/server/index.ts @@ -4,7 +4,9 @@ export { type AuthenticatedUser, type DeweeHandlerOptions, type DeweeIdentityOptions, + type DeweeIdentitySdk, type IdentityPrivateJwk, + type IdentitySigner, } from "./handler"; export { toNodeHandler } from "./node"; export { @@ -19,7 +21,15 @@ export { type GatewayCredentials, type StreamChatOptions, } from "./gateway-client"; -export { memoryRateLimiter, signVisitorToken, verifyVisitorToken, type RateLimiter, type VisitorClaims } from "./security"; +export { + memoryRateLimiter, + signVisitorToken, + verifyVisitorToken, + type MemoryRateLimits, + type RateLimitBucket, + type RateLimiter, + type VisitorClaims, +} from "./security"; export { ACTION_LIMITS, buildPageBlock, hashManifest, normalizeManifest, stripPageBlock } from "../actions/page-block"; export type { ActionManifestEntry, diff --git a/packages/dewee-webchat/src/server/security.ts b/packages/dewee-webchat/src/server/security.ts index 0e30c6d..8282342 100644 --- a/packages/dewee-webchat/src/server/security.ts +++ b/packages/dewee-webchat/src/server/security.ts @@ -28,18 +28,89 @@ export interface VisitorClaims { v: string; /** Expiry, epoch ms. */ exp: number; - /** Signed-in users only: the Dewee identity session token (one agent, chat only, short-lived). */ + /** + * Signed-in users only: the Dewee identity session token. In the signed token it is encrypted + * (AES-256-GCM), so the browser only ever holds an opaque value; these claims carry it decrypted. + */ ids?: string; /** Signed-in users only: the agent the identity session is bound to. */ ida?: string; + /** + * Signed-in users only: a keyed digest of the host's subject (see {@link subjectDigest}). Sealed + * together with `ids`, so the browser never sees the subject or its digest. + */ + sub?: string; } /** Longest identity session token the gateway issues (matches the `@dewee/identity` limit). */ const MAX_IDENTITY_SESSION_LENGTH = 512; +/** Longest sealed form: 12-byte IV + 512 bytes + 16-byte tag, base64url. */ +const MAX_SEALED_LENGTH = Math.ceil(((12 + MAX_IDENTITY_SESSION_LENGTH + 16) * 4) / 3); const AGENT_CLAIM_RE = /^[A-Za-z0-9_.:-]{1,64}$/; +const sealKeyCache = new Map>(); +/** AES-256-GCM key for identity sessions, derived from the visitor secret with its own HKDF label. */ +function sealKey(secret: string): Promise { + let k = sealKeyCache.get(secret); + if (!k) { + k = crypto.subtle + .importKey("raw", enc.encode(secret), "HKDF", false, ["deriveKey"]) + .then((base) => + crypto.subtle.deriveKey( + { name: "HKDF", hash: "SHA-256", salt: enc.encode("dewee-webchat"), info: enc.encode("dewee-webchat:identity-session:aes-256-gcm:v1") }, + base, + { name: "AES-GCM", length: 256 }, + false, + ["encrypt", "decrypt"], + ), + ); + sealKeyCache.set(secret, k); + } + return k; +} + +/** The ciphertext is bound to the visitor and agent it was issued for. */ +const sealAad = (v: string, agent: string) => enc.encode(`dewee-webchat:ids:${v}:${agent}`); + +/** What is sealed: the session token and, optionally, the subject digest. */ +interface SealedIdentity { t: string; h?: string } + +async function sealIdentitySession(secret: string, v: string, agent: string, sealed: SealedIdentity): Promise { + const iv = crypto.getRandomValues(new Uint8Array(12)); + const ct = new Uint8Array(await crypto.subtle.encrypt({ name: "AES-GCM", iv, additionalData: sealAad(v, agent) }, await sealKey(secret), enc.encode(JSON.stringify(sealed)))); + const out = new Uint8Array(iv.length + ct.length); + out.set(iv); + out.set(ct, iv.length); + return b64url(out); +} + +async function openIdentitySession(secret: string, v: string, agent: string, sealed: string): Promise { + const raw = fromB64url(sealed); + if (raw.length <= 12 + 16) return null; + try { + const pt = await crypto.subtle.decrypt({ name: "AES-GCM", iv: raw.slice(0, 12), additionalData: sealAad(v, agent) }, await sealKey(secret), raw.slice(12)); + const parsed = JSON.parse(new TextDecoder().decode(pt)) as SealedIdentity; + if (typeof parsed?.t !== "string" || parsed.t === "" || parsed.t.length > MAX_IDENTITY_SESSION_LENGTH) return null; + if (parsed.h !== undefined && typeof parsed.h !== "string") return null; + return parsed; + } catch { + return null; + } +} + +/** Keyed digest of a host subject, for binding a token to the user it was issued for without storing the subject. */ +export async function subjectDigest(secret: string, subject: string): Promise { + const mac = new Uint8Array(await crypto.subtle.sign("HMAC", await hmacKey(secret), enc.encode(`dewee-webchat:subject:${subject}`))); + return b64url(mac); +} + export async function signVisitorToken(secret: string, claims: VisitorClaims): Promise { - const body = b64url(enc.encode(JSON.stringify(claims))); + let wire: Omit = claims; + if (claims.ids !== undefined && claims.ida !== undefined) { + const { sub, ...rest } = claims; + wire = { ...rest, ids: await sealIdentitySession(secret, claims.v, claims.ida, sub ? { t: claims.ids, h: sub } : { t: claims.ids }) }; + } + const body = b64url(enc.encode(JSON.stringify(wire))); const sig = new Uint8Array(await crypto.subtle.sign("HMAC", await hmacKey(secret), enc.encode(body))); return `${body}.${b64url(sig)}`; } @@ -55,8 +126,12 @@ export async function verifyVisitorToken(secret: string, token: string): Promise if (typeof claims.exp !== "number" || claims.exp < Date.now()) return null; // An identity claim is all or nothing: a session token always travels with the agent it is bound to. if (claims.ids !== undefined || claims.ida !== undefined) { - if (typeof claims.ids !== "string" || claims.ids === "" || claims.ids.length > MAX_IDENTITY_SESSION_LENGTH) return null; + if (typeof claims.ids !== "string" || claims.ids === "" || claims.ids.length > MAX_SEALED_LENGTH) return null; if (typeof claims.ida !== "string" || !AGENT_CLAIM_RE.test(claims.ida)) return null; + if ((claims as { sub?: unknown }).sub !== undefined) return null; // the digest only ever travels sealed + const opened = await openIdentitySession(secret, claims.v, claims.ida, claims.ids); + if (!opened) return null; + return opened.h ? { v: claims.v, exp: claims.exp, ids: opened.t, ida: claims.ida, sub: opened.h } : { v: claims.v, exp: claims.exp, ids: opened.t, ida: claims.ida }; } return claims; } catch { @@ -77,17 +152,32 @@ export function randomId(bytes = 16): string { return b64url(crypto.getRandomValues(new Uint8Array(bytes))); } +/** + * Rate-limit buckets: `session` (new visitor sessions, per IP), `chat` (messages, per visitor and IP), + * and with signed-in users `verified` (signed-in `/session` calls, per user) and `link` (link starts, + * per user). + */ +export type RateLimitBucket = "session" | "chat" | "verified" | "link"; + /** Returns true when the key may proceed. Implementations may be async (KV, DO, Redis…). */ -export type RateLimiter = (key: string, bucket: "session" | "chat") => boolean | Promise; +export type RateLimiter = (key: string, bucket: RateLimitBucket) => boolean | Promise; + +/** `[max, windowMs]` per bucket. `verified` and `link` fall back to the `session` limit when not set. */ +export interface MemoryRateLimits { + chat: [number, number]; + session: [number, number]; + verified?: [number, number]; + link?: [number, number]; +} /** * Fixed-window in-memory limiter. Good enough for a single Node process; on serverless * runtimes each isolate keeps its own counters, so pass a shared limiter for hard limits. */ -export function memoryRateLimiter(limits: { chat: [number, number]; session: [number, number] }): RateLimiter { +export function memoryRateLimiter(limits: MemoryRateLimits): RateLimiter { const windows = new Map(); return (key, bucket) => { - const [max, windowMs] = limits[bucket]; + const [max, windowMs] = limits[bucket] ?? limits.session; const id = `${bucket}:${key}`; const now = Date.now(); const w = windows.get(id); diff --git a/packages/dewee-webchat/src/widget/api-client.ts b/packages/dewee-webchat/src/widget/api-client.ts index a154861..3f62eeb 100644 --- a/packages/dewee-webchat/src/widget/api-client.ts +++ b/packages/dewee-webchat/src/widget/api-client.ts @@ -13,8 +13,9 @@ import { /** Identity errors after which the signed-in session is dropped and `/session` is requested again. */ const RENEW_CODES = new Set(["LINK_EXPIRED", "LINK_REVOKED"]); -/** Refresh margins: anonymous visitor tokens live 30 days, identity sessions minutes to hours. */ +/** Refresh margin of anonymous visitor tokens (they live 30 days). */ const ANON_REFRESH_MS = 86_400_000; +/** Signed-in sessions live minutes to hours: refresh this long before expiry, at most half the lifetime. */ const VERIFIED_REFRESH_MS = 60_000; export interface ApiClientOptions { @@ -82,8 +83,23 @@ export class ApiClient { return this.config; } + /** When each signed-in session should be refreshed, on this browser's clock. */ + private refreshAt = new WeakMap(); + private fresh(s: SessionResponse): boolean { - return s.expiresAt - Date.now() > (s.verified ? VERIFIED_REFRESH_MS : ANON_REFRESH_MS); + if (s.verified) return Date.now() < (this.refreshAt.get(s) ?? 0); + return s.expiresAt - Date.now() > ANON_REFRESH_MS; + } + + /** + * A signed-in session is refreshed `min(60s, lifetime/2)` before it ends, with the lifetime the + * backend reports (`expiresIn`) counted from receipt, so a short lifetime is not refreshed on every + * request and a skewed browser clock does not matter. + */ + private scheduleRefresh(s: SessionResponse): void { + const now = Date.now(); + const life = typeof s.expiresIn === "number" && s.expiresIn > 0 ? s.expiresIn : s.expiresAt - now; + this.refreshAt.set(s, now + life - Math.min(VERIFIED_REFRESH_MS, life / 2)); } /** The stored anonymous visitor session, if any. Signed-in sessions are never stored. */ @@ -132,7 +148,8 @@ export class ApiClient { this.session = s; // A signed-in session stays in memory only: the next page load asks the backend again, so a // sign-out or a different user never inherits it. - if (!s.verified) store.set(this.storageKey, JSON.stringify(s)); + if (s.verified) this.scheduleRefresh(s); + else store.set(this.storageKey, JSON.stringify(s)); return s; } @@ -157,17 +174,29 @@ export class ApiClient { /** Starts a web-first link of a chat account for the signed-in user (identity backends only). */ async link(): Promise { - await this.ensureSession(); - const res = await fetch(this.url("link"), { method: "POST", headers: this.headers(true), body: "{}" }); + const res = await this.signedInRequest(() => fetch(this.url("link"), { method: "POST", headers: this.headers(true), body: "{}" })); return this.parse(res); } async history(agent: string, conversationId: string): Promise { - await this.ensureSession(); - const res = await fetch(this.url("history", { agent, conversationId }), { headers: this.headers(true) }); + const res = await this.signedInRequest(() => fetch(this.url("history", { agent, conversationId }), { headers: this.headers(true) })); return (await this.parse<{ messages: HistoryMessage[] }>(res)).messages; } + /** + * Sends a request with the current session. A signed-in session the backend refuses (expired, + * revoked, or another user signed in) is dropped and renewed once, as chat() does. An anonymous + * visitor's 401 is final, and its stored visitor is kept. + */ + private async signedInRequest(send: () => Promise): Promise { + await this.ensureSession(); + const res = await send(); + if (res.status !== 401 || !this.session?.verified) return res; + this.forgetSession(); + await this.ensureSession(); + return send(); + } + /** Sends a message and calls `onEvent` for every streamed event. Resolves when the run ends. */ async chat( input: ChatRequestBody & { agent: string; conversationId: string }, diff --git a/packages/dewee-webchat/src/widget/element.ts b/packages/dewee-webchat/src/widget/element.ts index 2a1f379..14075a9 100644 --- a/packages/dewee-webchat/src/widget/element.ts +++ b/packages/dewee-webchat/src/widget/element.ts @@ -1,5 +1,5 @@ // — framework-agnostic chat widget (Shadow DOM, zero dependencies). -import { isIdentityErrorCode, type ActionSensitivity, type AgentInfo, type ChatRequestBody, type ChatStreamEvent, type HistoryMessage, type LinkResponse, type PublicConfig, type ToolCallView } from "../protocol"; +import { isIdentityErrorCode, isLinkErrorCode, type ActionSensitivity, type AgentInfo, type ChatRequestBody, type ChatStreamEvent, type HistoryMessage, type LinkResponse, type PublicConfig, type ToolCallView } from "../protocol"; import { ActionRegistry, runActionCall, type ActionCallStatus, type ActionConfirmHook, type PageAction, type PageActionInfo } from "../actions/action-runner"; import { formatActionResults, @@ -967,7 +967,8 @@ export class DeweeChatElement extends ElementBase { private showError(m: ViewMessage | null, err: Error) { const code = (err as ApiError).code; // Identity errors get the widget's own localized copy; the server text is only a fallback. - const text = (isIdentityErrorCode(code) ? this.strings.identityErrors[code] : "") || err.message || this.strings.errorGeneric; + const localized = isIdentityErrorCode(code) ? this.strings.identityErrors[code] : isLinkErrorCode(code) ? this.strings.linkErrors[code] : ""; + const text = localized || err.message || this.strings.errorGeneric; const remediation = (err as ApiError).remediation; this.emit("dewee:error", remediation ? { message: text, code, remediation } : { message: text, code }); if (m) { diff --git a/packages/dewee-webchat/src/widget/i18n.ts b/packages/dewee-webchat/src/widget/i18n.ts index 2eeddca..181580d 100644 --- a/packages/dewee-webchat/src/widget/i18n.ts +++ b/packages/dewee-webchat/src/widget/i18n.ts @@ -1,4 +1,4 @@ -import type { IdentityErrorCode } from "../protocol"; +import type { IdentityErrorCode, LinkErrorCode } from "../protocol"; export interface Strings { title: string; @@ -30,6 +30,8 @@ export interface Strings { actionLimit: (n: number) => string; /** Shown for the typed identity errors of a signed-in chat. */ identityErrors: Record; + /** Shown when starting a link fails for a reason other than an identity error. */ + linkErrors: Record; linkIntro: string; linkContinueOn: (channel: string) => string; linkExpires: (minutes: number) => string; @@ -73,6 +75,11 @@ const en: Strings = { PERMISSION_DENIED: "You don't have access to this.", SOURCE_UNAVAILABLE: "We couldn't check your sign-in right now. Please try again shortly.", }, + linkErrors: { + idempotency_key_reused: "This link request was already used. Please try again.", + link_in_progress: "A link is already in progress. Use the code you got, or wait for it to expire.", + linking_unavailable: "Linking is not available right now. Please try again later.", + }, linkIntro: "Send this code in a direct message from the chat account you want to link:", linkContinueOn: (channel) => `Continue on ${channel}`, linkExpires: (m) => `The code expires in ${m} min.`, @@ -116,6 +123,11 @@ const vi: Strings = { PERMISSION_DENIED: "Bạn không có quyền thực hiện việc này.", SOURCE_UNAVAILABLE: "Hiện chưa kiểm tra được đăng nhập của bạn. Bạn thử lại sau ít phút nhé.", }, + linkErrors: { + idempotency_key_reused: "Yêu cầu liên kết này đã được dùng. Bạn thử lại nhé.", + link_in_progress: "Đang có một liên kết chưa hoàn tất. Hãy dùng mã đã nhận hoặc chờ mã hết hạn.", + linking_unavailable: "Hiện chưa thể liên kết. Bạn thử lại sau nhé.", + }, linkIntro: "Gửi mã này trong tin nhắn riêng từ tài khoản chat bạn muốn liên kết:", linkContinueOn: (channel) => `Tiếp tục trên ${channel}`, linkExpires: (m) => `Mã hết hạn sau ${m} phút.`, diff --git a/packages/dewee-webchat/test/identity.test.ts b/packages/dewee-webchat/test/identity.test.ts index 5120754..b5019eb 100644 --- a/packages/dewee-webchat/test/identity.test.ts +++ b/packages/dewee-webchat/test/identity.test.ts @@ -1,13 +1,22 @@ // Signed-in users through a Dewee identity session exchange, against a fake gateway that serves the // identity HTTP routes and the WS v3 protocol on one port. Covers isolation between users, fail-closed -// session issuance, typed error forwarding, web-first linking and secret hygiene. +// session issuance, typed error forwarding, session reuse and rate limits, web-first linking and +// secret hygiene. import { createHmac, generateKeyPairSync, randomUUID, verify as verifySignature, type KeyObject } from "node:crypto"; import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http"; import type { AddressInfo } from "node:net"; import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; import { WebSocketServer } from "ws"; import type { ChatStreamEvent } from "../src/protocol"; -import { createDeweeHandler, signVisitorToken, type DeweeHandlerOptions, type DeweeIdentityOptions } from "../src/server"; +import { + createDeweeHandler, + signVisitorToken, + verifyVisitorToken, + type DeweeHandlerOptions, + type DeweeIdentityOptions, + type RateLimitBucket, +} from "../src/server"; +import { deweeIdentitySdk } from "../src/server/identity"; import { ApiClient } from "../src/widget/api-client"; import { pickStrings } from "../src/widget/i18n"; @@ -27,7 +36,9 @@ interface Session { principal: string; agent: string; expiresAt: number } const sessions = new Map(); const expiredSessions = new Set(); const revokeDuringRun = new Set(); -const seen = { connects: [] as any[], sends: [] as any[], exchanges: [] as { sub: string; agent: string; ttl?: number }[] }; +const seen = { connects: [] as any[], sends: [] as any[], exchanges: [] as { sub: string; agent: string; ttl?: number; token?: string }[] }; +/** Simulates a gateway (or replica) that has identity switched off: it ignores `identity_session`. */ +const gw = { ignoreIdentity: false, linkDisabled: false }; let gatewayUrl = ""; let server: Server; @@ -46,7 +57,7 @@ function verifyAssertion(jws: string): { sub: string } | null { return { sub: claims.sub }; } -const identityError = (code: string, action: string) => ({ error: { code, message: code, type: "identity_error", remediation: { action, audience: "end_user" } } }); +const identityError = (code: string, action: string, audience = "end_user") => ({ error: { code, message: code, type: "identity_error", remediation: { action, audience } } }); function sendJson(res: ServerResponse, status: number, body: unknown) { res.writeHead(status, { "content-type": "application/json" }); @@ -61,9 +72,12 @@ async function onHttp(req: IncomingMessage, res: ServerResponse) { const auth = String(req.headers.authorization ?? ""); const proof = auth.startsWith("Dewee-Assertion ") ? verifyAssertion(auth.slice(16)) : null; if (!proof) return sendJson(res, 401, identityError("IDENTITY_REQUIRED", "reauthenticate")); - seen.exchanges.push({ sub: proof.sub, agent: body.agent_id, ttl: body.ttl_seconds }); + const record: (typeof seen.exchanges)[number] = { sub: proof.sub, agent: body.agent_id, ttl: body.ttl_seconds }; + seen.exchanges.push(record); if (proof.sub === "suspended-user") return sendJson(res, 409, identityError("LINK_REVOKED", "reauthenticate")); + if (proof.sub === "operator-problem") return sendJson(res, 403, identityError("PERMISSION_DENIED", "contact_admin", "operator")); const token = `idsess_${randomUUID()}`; + record.token = token; const expiresAt = Date.now() + (body.ttl_seconds ?? 3600) * 1000; // One principal per subject: a stable fake id derived from it. const principal = createHmac("sha256", "principal").update(proof.sub).digest("hex").slice(0, 32); @@ -74,8 +88,12 @@ async function onHttp(req: IncomingMessage, res: ServerResponse) { }); } if (req.method === "POST" && req.url === "/v1/identity/links") { + if (gw.linkDisabled) return sendJson(res, 404, { error: { code: "NOT_FOUND", message: "route not found" } }); const s = sessions.get(String(req.headers["x-dewee-identity-session"] ?? "")); if (!s) return sendJson(res, 401, identityError("IDENTITY_REQUIRED", "reauthenticate")); + const key = req.headers["idempotency-key"]; + if (key === "reused-key") return sendJson(res, 409, { error: { code: "IDEMPOTENCY_KEY_REUSED", message: "idempotency key reused" } }); + if (key === "busy-key") return sendJson(res, 409, { error: { code: "FAILED_PRECONDITION", message: "link already in progress" } }); return sendJson(res, 201, { transaction_id: randomUUID(), kind: "web_first", state: "awaiting_channel_proof", proof_code: "482913", expires_at: new Date(Date.now() + 600_000).toISOString(), created: true, @@ -99,15 +117,15 @@ beforeAll(async () => { if (f.method === "connect") { seen.connects.push(f.params); if (f.params.token !== GW_TOKEN) return err("UNAUTHORIZED"); - if (f.params.identity_session !== undefined) { + if (f.params.identity_session !== undefined && !gw.ignoreIdentity) { const s = sessions.get(f.params.identity_session); if (expiredSessions.has(f.params.identity_session)) return err("LINK_EXPIRED", "reauthenticate"); if (!s) return err("IDENTITY_REQUIRED", "reauthenticate"); if (f.params.user_id !== undefined && f.params.user_id !== `tuc:${s.principal}`) return err("IDENTITY_CONFLICT", "contact_admin"); user = `tuc:${s.principal}`; sessionToken = f.params.identity_session; - } else user = f.params.user_id; - return ok({ role: "operator" }); + } else user = f.params.user_id ?? ""; // identity switched off: the operator token's own (empty) user + return ok({ role: "operator", user_id: user }); } if (f.method === "chat.send") { seen.sends.push({ ...f.params, user }); @@ -139,6 +157,8 @@ const cookieUser = async (req: Request) => { return uid ? { subject: uid } : null; }; +const baseIdentity = { sdk: deweeIdentitySdk, authenticate: cookieUser, appId: APP_ID, issuer: ISSUER, assertionSecret: ASSERTION_SECRET }; + function makeHandler(identity: Partial = {}, extra: Partial = {}) { const h = createDeweeHandler({ gatewayUrl, @@ -146,7 +166,7 @@ function makeHandler(identity: Partial = {}, extra: Partia secret: VISITOR_SECRET, agents: ["support", "sales"], rateLimit: false, - identity: { authenticate: cookieUser, appId: APP_ID, issuer: ISSUER, assertionSecret: ASSERTION_SECRET, ...identity }, + identity: { ...baseIdentity, ...identity }, ...extra, }); return async (req: Request) => { @@ -163,8 +183,22 @@ const session = (h: TestHandler, uid?: string, body: unknown = {}) => h(new Request(`${BASE}/session`, { method: "POST", headers: uid ? { cookie: `uid=${uid}` } : {}, body: JSON.stringify(body) })); const chat = (h: TestHandler, token: string, body: Record = {}, cookie?: string) => h(new Request(`${BASE}/chat`, { method: "POST", headers: { authorization: `Bearer ${token}`, ...(cookie ? { cookie } : {}) }, body: JSON.stringify({ message: "hi", conversationId: "conv_ident1", ...body }) })); +const history = (h: TestHandler, token: string, cookie?: string) => + h(new Request(`${BASE}/history?agent=support&conversationId=conv_ident1`, { headers: { authorization: `Bearer ${token}`, ...(cookie ? { cookie } : {}) } })); +const link = (h: TestHandler, token: string, body: unknown = {}, cookie?: string) => + h(new Request(`${BASE}/link`, { method: "POST", headers: { authorization: `Bearer ${token}`, ...(cookie ? { cookie } : {}) }, body: JSON.stringify(body) })); const sse = (text: string): ChatStreamEvent[] => text.split("\n\n").filter((f) => f.startsWith("data:")).map((f) => JSON.parse(f.slice(5))); const claimsOf = (token: string) => b64json(token.split(".")[0]); +/** The raw identity session inside a visitor token, as only the server can read it. */ +const rawIds = async (token: string) => (await verifyVisitorToken(VISITOR_SECRET, token))?.ids; +const quiet = async (fn: () => Promise): Promise => { + const spies = [vi.spyOn(console, "warn").mockImplementation(() => {}), vi.spyOn(console, "error").mockImplementation(() => {})]; + try { + return await fn(); + } finally { + for (const s of spies) s.mockRestore(); + } +}; describe("identity sessions: isolation", () => { it("gives users A and B different identity sessions and never sends a client-chosen user_id", async () => { @@ -173,15 +207,17 @@ describe("identity sessions: isolation", () => { const b = (await session(h, "user-b")).json(); expect(a.verified).toBe(true); expect(b.verified).toBe(true); - expect(claimsOf(a.token).ids).not.toBe(claimsOf(b.token).ids); + const [idsA, idsB] = [await rawIds(a.token), await rawIds(b.token)]; + expect(idsA).toMatch(/^idsess_/); + expect(idsA).not.toBe(idsB); expect(a.visitorId).not.toBe(b.visitorId); const ra = sse((await chat(h, a.token, { agent: "support", userId: "evil" })).text); const connA = seen.connects.at(-1); - expect(connA).toEqual({ token: GW_TOKEN, identity_session: claimsOf(a.token).ids }); + expect(connA).toEqual({ token: GW_TOKEN, identity_session: idsA }); expect(connA).not.toHaveProperty("user_id"); const rb = sse((await chat(h, b.token)).text); - expect(seen.connects.at(-1).identity_session).toBe(claimsOf(b.token).ids); + expect(seen.connects.at(-1).identity_session).toBe(idsB); expect(ra.at(-1)).toMatchObject({ type: "done" }); expect((ra.at(-1) as any).content).not.toBe((rb.at(-1) as any).content); }); @@ -191,16 +227,15 @@ describe("identity sessions: isolation", () => { const a = (await session(h, "user-a")).json(); const asA = sse((await chat(h, a.token)).text).at(-1) as any; const replay = sse((await chat(h, a.token, {}, "uid=user-b")).text).at(-1) as any; - expect(seen.connects.at(-1).identity_session).toBe(claimsOf(a.token).ids); + expect(seen.connects.at(-1).identity_session).toBe(await rawIds(a.token)); expect(replay.content).toBe(asA.content); }); - it("keeps the same visitor id for the same user across sessions, so history follows them", async () => { + it("keeps the same visitor id for the same user, so history follows them", async () => { const h = makeHandler(); const first = (await session(h, "user-a")).json(); const second = (await session(h, "user-a")).json(); expect(second.visitorId).toBe(first.visitorId); - expect(claimsOf(second.token).ids).not.toBe(claimsOf(first.token).ids); }); it("answers 401 to a tampered or foreign-signed token", async () => { @@ -210,27 +245,51 @@ describe("identity sessions: isolation", () => { const [, sig] = a.token.split("."); const swapped = Buffer.from(JSON.stringify({ ...claimsOf(a.token), ids: claimsOf(b.token).ids })).toString("base64url"); expect((await chat(h, `${swapped}.${sig}`)).status).toBe(401); - const forged = await signVisitorToken("another-secret-of-at-least-32-chars!!", { v: "visitor_123", exp: Date.now() + 60_000, ids: claimsOf(b.token).ids, ida: "support" }); + const forged = await signVisitorToken("another-secret-of-at-least-32-chars!!", { v: "visitor_123", exp: Date.now() + 60_000, ids: (await rawIds(b.token))!, ida: "support" }); expect((await chat(h, forged)).status).toBe(401); }); - it("binds the session to one agent", async () => { + it("binds the session to one agent that must still be allowed", async () => { const h = makeHandler(); const cfg = (await h(new Request(`${BASE}/config`))).json(); expect(cfg).toMatchObject({ identity: true, allowAgentSwitch: false, defaultAgent: "support" }); const a = (await session(h, "user-a")).json(); expect(seen.exchanges.at(-1)).toMatchObject({ sub: "user-a", agent: "support" }); expect((await chat(h, a.token, { agent: "sales" })).status).toBe(403); + // The same secret after "support" left the allowlist: the old token is refused for chat and history. + const narrowed = makeHandler({ agent: "sales" }, { agents: ["sales"] }); + const r = await chat(narrowed, a.token); + expect([r.status, r.json().error.code]).toEqual([403, "agent_not_allowed"]); + expect((await history(narrowed, a.token)).status).toBe(403); + }); +}); + +describe("identity sessions: sealed token", () => { + it("never puts the raw identity session or the subject in the browser token", async () => { + const h = makeHandler(); + const a = (await session(h, "user-a")).json(); + const raw = (await rawIds(a.token))!; + const decoded = Buffer.from(a.token.split(".")[0], "base64url").toString("utf8"); + expect(decoded).not.toContain(raw); + expect(decoded).not.toContain("user-a"); + expect(Object.keys(claimsOf(a.token)).sort()).toEqual(["exp", "ida", "ids", "v"]); + // Sealed for this visitor and agent: moving the ciphertext into another visitor's claims breaks it. + const other = await signVisitorToken(VISITOR_SECRET, { v: "visitor_other1", exp: Date.now() + 60_000 }); + const moved = { ...claimsOf(other), ids: claimsOf(a.token).ids, ida: "support" }; + const body = Buffer.from(JSON.stringify(moved)).toString("base64url"); + const sig = createHmac("sha256", VISITOR_SECRET).update(body).digest("base64url"); + expect(await verifyVisitorToken(VISITOR_SECRET, `${body}.${sig}`)).toBeNull(); }); }); describe("identity sessions: fail closed", () => { it("refuses identity mode combined with client credentials and other misconfiguration", () => { const base = { gatewayUrl: "https://gw.example.com", token: GW_TOKEN, secret: VISITOR_SECRET, agents: ["support"] }; - const identity = { authenticate: cookieUser, appId: APP_ID, issuer: ISSUER, assertionSecret: ASSERTION_SECRET }; + const identity = baseIdentity; expect(() => createDeweeHandler({ ...base, identity, allowClientCredentials: true })).toThrow(/allowClientCredentials/); expect(() => createDeweeHandler({ ...base, identity, allowClientCredentials: { allowedHosts: ["gw.example.com"] } })).toThrow(/allowClientCredentials/); expect(() => createDeweeHandler({ ...base, token: undefined, identity })).toThrow(/gatewayUrl and token/); + expect(() => createDeweeHandler({ ...base, identity: { ...identity, sdk: undefined as never } })).toThrow(/dewee-webchat\/server\/identity/); expect(() => createDeweeHandler({ ...base, identity: { ...identity, privateKeyJwk: privateJwk } })).toThrow(/exactly one/); expect(() => createDeweeHandler({ ...base, identity: { ...identity, audience: "dewee:someone-else" } })).toThrow(/audience/); expect(() => createDeweeHandler({ ...base, identity: { ...identity, appId: "acme" } })).toThrow(/appId/); @@ -240,16 +299,21 @@ describe("identity sessions: fail closed", () => { try { createDeweeHandler({ ...base, identity, allowClientCredentials: true }); } catch (e) { expect(String(e)).not.toContain(ASSERTION_SECRET); } }); - it("falls back to an anonymous visitor only when identity is not required", async () => { + it("falls back to an anonymous visitor only for a visitor who is not signed in", async () => { const h = makeHandler(); - for (const uid of [undefined, "boom"]) { - const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); - const s = (await session(h, uid)).json(); - warn.mockRestore(); - expect(s.verified).toBeUndefined(); - expect(claimsOf(s.token)).not.toHaveProperty("ids"); - await chat(h, s.token); - expect(seen.connects.at(-1)).toEqual({ token: GW_TOKEN, user_id: `webchat-${s.visitorId}` }); + const s = (await session(h)).json(); + expect(s.verified).toBeUndefined(); + expect(s).not.toHaveProperty("expiresIn"); + expect(claimsOf(s.token)).not.toHaveProperty("ids"); + await chat(h, s.token); + expect(seen.connects.at(-1)).toEqual({ token: GW_TOKEN, user_id: `webchat-${s.visitorId}` }); + }); + + it("answers 503 and issues nothing when authenticate throws, required or not", async () => { + for (const required of [false, true]) { + const r = await quiet(() => session(makeHandler({ required }), "boom")); + expect(r.status).toBe(503); + expect(r.json()).toEqual({ error: { message: expect.any(String), code: "SOURCE_UNAVAILABLE", remediation: { action: "retry_later", audience: "end_user" } } }); } }); @@ -258,12 +322,6 @@ describe("identity sessions: fail closed", () => { const none = await session(h); expect(none.status).toBe(401); expect(none.json()).toEqual({ error: { message: expect.any(String), code: "IDENTITY_REQUIRED", remediation: { action: "reauthenticate", audience: "end_user" } } }); - const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); - const boom = await session(h, "boom"); - warn.mockRestore(); - expect(boom.status).toBe(503); - expect(boom.json().error.code).toBe("SOURCE_UNAVAILABLE"); - expect(boom.json()).not.toHaveProperty("token"); // An anonymous visitor token from before the switch is refused too. const anon = await signVisitorToken(VISITOR_SECRET, { v: "visitor_123", exp: Date.now() + 60_000 }); const r = await chat(h, anon); @@ -272,25 +330,48 @@ describe("identity sessions: fail closed", () => { it("never downgrades a signed-in user whose exchange is refused", async () => { const h = makeHandler(); - const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); - const r = await session(h, "suspended-user"); - const bad = await makeHandler({ assertionSecret: "wrong-secret-but-long-enough-0000000000001" })( - new Request(`${BASE}/session`, { method: "POST", headers: { cookie: "uid=user-a" }, body: "{}" }), - ); - const email = await session(h, "someone@example.com"); - warn.mockRestore(); + const [r, bad, email, operator] = await quiet(async () => [ + await session(h, "suspended-user"), + await makeHandler({ assertionSecret: "wrong-secret-but-long-enough-0000000000001" })( + new Request(`${BASE}/session`, { method: "POST", headers: { cookie: "uid=user-a" }, body: "{}" }), + ), + await session(h, "someone@example.com"), + await session(h, "operator-problem"), + ]); expect(r.status).toBe(401); expect(r.json()).toEqual({ error: { message: expect.any(String), code: "LINK_REVOKED", remediation: { action: "reauthenticate", audience: "end_user" } } }); expect([bad.status, bad.json().error.code]).toEqual([401, "IDENTITY_REQUIRED"]); expect([email.status, email.json().error.code]).toEqual([500, "identity_misconfigured"]); - for (const res of [r, bad, email]) expect(res.json()).not.toHaveProperty("token"); + // A refusal addressed to the operator is this server's misconfiguration, not the user's problem. + expect(operator.json()).toEqual({ error: { message: expect.any(String), code: "identity_misconfigured" } }); + expect(operator.status).toBe(500); + for (const res of [r, bad, email, operator]) expect(res.json()).not.toHaveProperty("token"); }); - it("caps the visitor token at the identity session expiry", async () => { + it("refuses to chat when the gateway ignores the identity session (identity switched off)", async () => { + const h = makeHandler(); + const a = (await session(h, "user-a")).json(); + const sends = seen.sends.length; + gw.ignoreIdentity = true; + try { + const r = await quiet(() => chat(h, a.token)); + expect(r.status).toBe(503); + expect(r.json().error.code).toBe("SOURCE_UNAVAILABLE"); + const hist = await quiet(() => history(h, a.token)); + expect([hist.status, hist.json().error.code]).toEqual([503, "SOURCE_UNAVAILABLE"]); + expect(seen.sends.length).toBe(sends); // nothing ran as the operator's user + } finally { + gw.ignoreIdentity = false; + } + }); + + it("caps the visitor token at the identity session lifetime, measured from receipt", async () => { const short = (await session(makeHandler({ sessionTtlSeconds: 120 }), "user-a")).json(); expect(seen.exchanges.at(-1)?.ttl).toBe(120); expect(short.expiresAt).toBeLessThanOrEqual(Date.now() + 120_000); expect(short.expiresAt).toBeGreaterThan(Date.now() + 100_000); + expect(short.expiresIn).toBeGreaterThan(100_000); + expect(short.expiresIn).toBeLessThanOrEqual(120_000); expect(claimsOf(short.token).exp).toBe(short.expiresAt); const visitorCap = (await session(makeHandler({}, { sessionTtlMs: 30_000 }), "user-a")).json(); expect(visitorCap.expiresAt).toBeLessThanOrEqual(Date.now() + 30_000); @@ -307,22 +388,111 @@ describe("identity sessions: fail closed", () => { }); }); +describe("identity sessions: rebinding each request", () => { + it("refuses A's token under B's cookie or no cookie, and accepts it under A's cookie", async () => { + const h = makeHandler({ rebindEachRequest: true }); + const a = (await session(h, "user-a")).json(); + const asA = await chat(h, a.token, {}, "uid=user-a"); + expect(asA.status).toBe(200); + expect(sse(asA.text).at(-1)).toMatchObject({ type: "done" }); + expect((await history(h, a.token, "uid=user-a")).status).toBe(200); + for (const cookie of ["uid=user-b", undefined]) { + const r = await chat(h, a.token, {}, cookie); + expect(r.json()).toEqual({ error: { message: expect.any(String), code: "IDENTITY_REQUIRED", remediation: { action: "reauthenticate", audience: "end_user" } } }); + expect(r.status).toBe(401); + expect((await history(h, a.token, cookie)).status).toBe(401); + expect((await link(h, a.token, {}, cookie)).status).toBe(401); + } + }); + + it("answers 503 when authenticate throws", async () => { + const h = makeHandler({ rebindEachRequest: true }); + const a = (await session(h, "user-a")).json(); + const r = await quiet(() => chat(h, a.token, {}, "uid=boom")); + expect([r.status, r.json().error.code]).toEqual([503, "SOURCE_UNAVAILABLE"]); + }); + + it("is off by default (see the isolation tests) and ignores anonymous visitors", async () => { + const h = makeHandler({ rebindEachRequest: true }); + const anon = (await session(h)).json(); + expect((await chat(h, anon.token, {}, "uid=user-b")).status).toBe(200); + }); +}); + +describe("identity sessions: reuse and rate limits", () => { + it("reuses one identity session per user until it nears expiry, and drops it once the gateway says it is dead", async () => { + const h = makeHandler(); + const before = seen.exchanges.length; + const first = (await session(h, "user-a")).json(); + const second = (await session(h, "user-a")).json(); + expect(seen.exchanges.length).toBe(before + 1); + expect(await rawIds(second.token)).toBe(await rawIds(first.token)); + expiredSessions.add((await rawIds(first.token))!); + expect((await chat(h, first.token)).status).toBe(401); + const third = (await session(h, "user-a")).json(); + expect(seen.exchanges.length).toBe(before + 2); + expect(await rawIds(third.token)).not.toBe(await rawIds(first.token)); + }); + + it("re-exchanges a short session only near its end (min(60s, ttl/2))", async () => { + vi.useFakeTimers({ toFake: ["Date"] }); + try { + const h = makeHandler({ sessionTtlSeconds: 60 }); + const before = seen.exchanges.length; + await session(h, "user-a"); + vi.setSystemTime(Date.now() + 20_000); + await session(h, "user-a"); + expect(seen.exchanges.length).toBe(before + 1); + vi.setSystemTime(Date.now() + 15_000); // 35 s in: inside the last 30 s + await session(h, "user-a"); + expect(seen.exchanges.length).toBe(before + 2); + } finally { + vi.useRealTimers(); + } + }); + + it("limits signed-in sessions per user, links per user, and returns a valid anonymous token without minting", async () => { + const calls: Array<[string, RateLimitBucket]> = []; + const h = makeHandler({}, { rateLimit: (key, bucket) => (calls.push([key, bucket]), true) }); + const a = (await session(h, "user-a")).json(); + expect(calls).toEqual([["subject:user-a", "verified"]]); + calls.length = 0; + expect((await link(h, a.token)).status).toBe(200); + expect(calls).toEqual([[`visitor:${a.visitorId}`, "link"]]); + calls.length = 0; + const anon = (await session(h)).json(); + expect(calls.map(([, b]) => b)).toEqual(["session"]); + calls.length = 0; + const again = (await session(h, undefined, { token: anon.token })).json(); + expect(again).toEqual({ visitorId: anon.visitorId, token: anon.token, expiresAt: anon.expiresAt }); + expect(calls).toEqual([]); + }); + + it("answers 429 once a user's signed-in session budget is spent", async () => { + const h = makeHandler({}, { rateLimit: undefined }); + let last = 0; + for (let i = 0; i < 31; i++) last = (await session(h, "busy-user")).status; + expect(last).toBe(429); + expect((await session(h, "user-b")).status).toBe(200); + }); +}); + describe("identity sessions: typed gateway errors", () => { it("forwards LINK_EXPIRED at connect as a typed 401 for chat and history", async () => { const h = makeHandler(); const a = (await session(h, "user-a")).json(); - expiredSessions.add(claimsOf(a.token).ids); + expiredSessions.add((await rawIds(a.token))!); const r = await chat(h, a.token); expect(r.status).toBe(401); expect(r.json()).toEqual({ error: { message: expect.any(String), code: "LINK_EXPIRED", remediation: { action: "reauthenticate", audience: "end_user" } } }); - const hist = await h(new Request(`${BASE}/history?agent=support&conversationId=conv_ident1`, { headers: { authorization: `Bearer ${a.token}` } })); + const hist = await history(h, a.token); expect([hist.status, hist.json().error.code]).toEqual([401, "LINK_EXPIRED"]); }); it("forwards an identity error raised during the run in the event stream", async () => { const h = makeHandler(); const a = (await session(h, "user-a")).json(); - revokeDuringRun.add(claimsOf(a.token).ids); + revokeDuringRun.add((await rawIds(a.token))!); const events = sse((await chat(h, a.token)).text); expect(events.at(-1)).toEqual({ type: "error", message: expect.any(String), code: "LINK_REVOKED", remediation: { action: "reauthenticate", audience: "end_user" } }); }); @@ -330,7 +500,7 @@ describe("identity sessions: typed gateway errors", () => { it("reads history as the session's user", async () => { const h = makeHandler(); const a = (await session(h, "user-a")).json(); - const r = await h(new Request(`${BASE}/history?agent=support&conversationId=conv_ident1`, { headers: { authorization: `Bearer ${a.token}` } })); + const r = await history(h, a.token); expect(r.json().messages[0].content).toMatch(/^history of tuc:/); expect(seen.connects.at(-1)).not.toHaveProperty("user_id"); }); @@ -340,7 +510,7 @@ describe("web-first linking", () => { it("returns the proof code and the chat accounts to send it to", async () => { const h = makeHandler(); const a = (await session(h, "user-a")).json(); - const r = await h(new Request(`${BASE}/link`, { method: "POST", headers: { authorization: `Bearer ${a.token}` }, body: JSON.stringify({ idempotencyKey: "req-1" }) })); + const r = await link(h, a.token, { idempotencyKey: "req-1" }); expect(r.status).toBe(200); const body = r.json(); expect(body).toMatchObject({ proofCode: "482913", deepLinks: [{ channelType: "telegram", name: "acme-bot", displayName: "Acme Bot" }] }); @@ -350,9 +520,25 @@ describe("web-first linking", () => { it("refuses visitors who are not signed in", async () => { const h = makeHandler(); const anon = (await session(h)).json(); - const r = await h(new Request(`${BASE}/link`, { method: "POST", headers: { authorization: `Bearer ${anon.token}` }, body: "{}" })); + const r = await link(h, anon.token); expect([r.status, r.json().error.code]).toEqual([401, "IDENTITY_REQUIRED"]); }); + + it("maps the gateway's link conflicts and a switched-off gateway to their own codes", async () => { + const h = makeHandler(); + const a = (await session(h, "user-a")).json(); + const reused = await quiet(() => link(h, a.token, { idempotencyKey: "reused-key" })); + expect([reused.status, reused.json().error.code]).toEqual([409, "idempotency_key_reused"]); + const busy = await quiet(() => link(h, a.token, { idempotencyKey: "busy-key" })); + expect([busy.status, busy.json().error.code]).toEqual([409, "link_in_progress"]); + gw.linkDisabled = true; + try { + const off = await quiet(() => link(h, a.token)); + expect([off.status, off.json().error.code]).toEqual([503, "linking_unavailable"]); + } finally { + gw.linkDisabled = false; + } + }); }); describe("jwks applications", () => { @@ -365,48 +551,98 @@ describe("jwks applications", () => { }); describe("widget client", () => { - it("re-requests /session on LINK_EXPIRED and keeps signed-in sessions out of storage", async () => { - const storage = new Map(); + const json = (status: number, v: unknown) => new Response(JSON.stringify(v), { status, headers: { "content-type": "application/json" } }); + const config = { agents: [{ id: "support" }], defaultAgent: "support", allowAgentSwitch: false, allowClientCredentials: false, maxMessageLength: 4000, version: "t", identity: true }; + const done = () => new Response(`data: ${JSON.stringify({ type: "done", content: "ok" })}\n\n`, { headers: { "content-type": "text/event-stream" } }); + + /** Runs `fn` with a fake backend; `route` answers each call by path. Returns the paths called. */ + async function withBackend(route: (path: string, n: number, init?: RequestInit) => Response, fn: (api: ApiClient) => Promise, storage = new Map()) { vi.stubGlobal("localStorage", { getItem: (k: string) => storage.get(k) ?? null, setItem: (k: string, v: string) => void storage.set(k, v), removeItem: (k: string) => void storage.delete(k) }); const calls: string[] = []; - let chats = 0; - const json = (status: number, v: unknown) => new Response(JSON.stringify(v), { status, headers: { "content-type": "application/json" } }); vi.stubGlobal("fetch", vi.fn(async (url: string, init?: RequestInit) => { const path = new URL(url, "https://site.test").pathname.split("/").pop()!; calls.push(path); - if (path === "config") return json(200, { agents: [{ id: "support" }], defaultAgent: "support", allowAgentSwitch: false, allowClientCredentials: false, maxMessageLength: 4000, version: "t", identity: true }); - if (path === "session") return json(200, { visitorId: "v_signed_in1", token: `tok${calls.length}`, expiresAt: Date.now() + 3_600_000, verified: true }); - if (path === "chat" && ++chats === 1) return json(401, { error: { message: "expired", code: "LINK_EXPIRED", remediation: { action: "reauthenticate", audience: "end_user" } } }); - expect((init?.headers as Record).authorization).toMatch(/^Bearer tok/); - return new Response(`data: ${JSON.stringify({ type: "done", content: "ok" })}\n\n`, { headers: { "content-type": "text/event-stream" } }); + if (path === "config") return json(200, config); + return route(path, calls.filter((c) => c === path).length, init); })); try { - const api = new ApiClient({ endpoint: "/api/dewee" }); - const events: ChatStreamEvent[] = []; - await api.chat({ message: "hi", agent: "support", conversationId: "conv_ident1" }, (e) => events.push(e)); - expect(calls).toEqual(["config", "session", "chat", "session", "chat"]); - expect(events).toEqual([{ type: "done", content: "ok" }]); - expect([...storage.keys()]).toEqual([]); + await fn(new ApiClient({ endpoint: "/api/dewee" })); } finally { vi.unstubAllGlobals(); } + return calls; + } + + const signedIn = (n: number, extra: Record = {}) => json(200, { visitorId: "v_signed_in1", token: `tok${n}`, expiresAt: Date.now() + 3_600_000, verified: true, ...extra }); + + it("re-requests /session on LINK_EXPIRED and keeps signed-in sessions out of storage", async () => { + const storage = new Map(); + const events: ChatStreamEvent[] = []; + const calls = await withBackend((path, n, init) => { + if (path === "session") return signedIn(n); + if (path === "chat" && n === 1) return json(401, { error: { message: "expired", code: "LINK_EXPIRED", remediation: { action: "reauthenticate", audience: "end_user" } } }); + expect((init?.headers as Record).authorization).toBe("Bearer tok2"); + return done(); + }, (api) => api.chat({ message: "hi", agent: "support", conversationId: "conv_ident1" }, (e) => events.push(e)), storage); + expect(calls).toEqual(["config", "session", "chat", "session", "chat"]); + expect(events).toEqual([{ type: "done", content: "ok" }]); + expect([...storage.keys()]).toEqual([]); + }); + + it("renews the session after a LINK_REVOKED event in the stream", async () => { + const calls = await withBackend((path, n) => { + if (path === "session") return signedIn(n); + return new Response(`data: ${JSON.stringify({ type: "error", message: "revoked", code: "LINK_REVOKED" })}\n\n`, { headers: { "content-type": "text/event-stream" } }); + }, async (api) => { + await api.chat({ message: "hi", agent: "support", conversationId: "conv_ident1" }, () => {}); + expect((await api.ensureSession()).token).toBe("tok2"); + }); + expect(calls).toEqual(["config", "session", "chat", "session"]); + }); + + it("renews an expired or revoked session once when starting a link", async () => { + const calls = await withBackend((path, n, init) => { + if (path === "session") return signedIn(n); + if (n === 1) return json(401, { error: { message: "revoked", code: "LINK_REVOKED" } }); + expect((init?.headers as Record).authorization).toBe("Bearer tok2"); + return json(200, { proofCode: "482913", expiresAt: Date.now() + 600_000, deepLinks: [] }); + }, async (api) => { + expect((await api.link()).proofCode).toBe("482913"); + }); + expect(calls).toEqual(["config", "session", "link", "session", "link"]); + }); + + it("refreshes a signed-in session from expiresIn, counted from receipt, not from a skewed expiresAt", async () => { + const calls = await withBackend((path, n) => { + // The browser clock is ahead: expiresAt already looks past, but the session has 90 s left. + if (path === "session") return signedIn(n, { expiresAt: Date.now() - 5_000, expiresIn: 90_000 }); + return done(); + }, async (api) => { + await api.chat({ message: "1", agent: "support", conversationId: "conv_ident1" }, () => {}); + await api.chat({ message: "2", agent: "support", conversationId: "conv_ident1" }, () => {}); + }); + expect(calls.filter((c) => c === "session")).toHaveLength(1); }); - it("has localized copy for every identity error", () => { + it("has localized copy for every identity and link error", () => { for (const locale of ["en", "vi"]) { const s = pickStrings(locale); for (const code of ["IDENTITY_REQUIRED", "IDENTITY_LINK_REQUIRED", "IDENTITY_CONFLICT", "LINK_EXPIRED", "LINK_REVOKED", "PERMISSION_DENIED", "SOURCE_UNAVAILABLE"] as const) { expect(s.identityErrors[code]).toBeTruthy(); } + for (const code of ["idempotency_key_reused", "link_in_progress", "linking_unavailable"] as const) expect(s.linkErrors[code]).toBeTruthy(); } expect(pickStrings("vi").identityErrors.LINK_EXPIRED).not.toBe(pickStrings("en").identityErrors.LINK_EXPIRED); }); }); describe("secret hygiene", () => { - it("never sends the gateway token, assertion secret, visitor secret or private key to a browser", () => { + it("never sends the gateway token, assertion secret, visitor secret, private key or a raw identity session to a browser", () => { expect(browserBodies.length).toBeGreaterThan(40); const all = browserBodies.join("\n"); for (const secret of [GW_TOKEN, ASSERTION_SECRET, VISITOR_SECRET, privateJwk.d]) expect(all).not.toContain(secret); + const issued = seen.exchanges.map((e) => e.token).filter((t): t is string => !!t); + expect(issued.length).toBeGreaterThan(10); + for (const token of issued) expect(all).not.toContain(token); }); }); diff --git a/packages/dewee-webchat/tsup.config.ts b/packages/dewee-webchat/tsup.config.ts index aaf1029..0b546a2 100644 --- a/packages/dewee-webchat/tsup.config.ts +++ b/packages/dewee-webchat/tsup.config.ts @@ -17,11 +17,11 @@ const esbuildPlugins = [ export default defineConfig([ { - entry: { index: "src/index.ts", server: "src/server/index.ts", react: "src/react.ts", vue: "src/vue.ts" }, + entry: { index: "src/index.ts", server: "src/server/index.ts", "server-identity": "src/server/identity.ts", react: "src/react.ts", vue: "src/vue.ts" }, format: ["esm"], dts: true, target: "es2022", - external: ["react", "vue"], + external: ["react", "vue", "@dewee/identity"], esbuildPlugins, }, { From 8421b8879ddfd0eb86408b68e04d90ce9a820f6d Mon Sep 17 00:00:00 2001 From: Duy Nguyen Date: Tue, 20 Oct 2026 17:48:45 +0700 Subject: [PATCH 5/7] test: bundle the anonymous server with webpack and esbuild without the identity SDK Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 2 + package.json | 1 + packages/dewee-webchat/package.json | 7 + .../dewee-webchat/scripts/bundler-smoke.mjs | 107 ++++ pnpm-lock.yaml | 540 ++++++++++++++++-- 5 files changed, 621 insertions(+), 36 deletions(-) create mode 100644 packages/dewee-webchat/scripts/bundler-smoke.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f31a931..12872fa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,6 +19,8 @@ jobs: - run: pnpm typecheck - run: pnpm test - run: pnpm build + # dewee-webchat/server must bundle (webpack, esbuild) without the optional @dewee/identity SDK. + - run: pnpm test:bundlers php: runs-on: ubuntu-latest diff --git a/package.json b/package.json index 31aa1dd..9d7766b 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "scripts": { "build": "pnpm --filter dewee-webchat build", "test": "pnpm --filter dewee-webchat test", + "test:bundlers": "pnpm --filter dewee-webchat test:bundlers", "typecheck": "pnpm --filter dewee-webchat typecheck" }, "pnpm": { diff --git a/packages/dewee-webchat/package.json b/packages/dewee-webchat/package.json index 09e4fec..06b477c 100644 --- a/packages/dewee-webchat/package.json +++ b/packages/dewee-webchat/package.json @@ -45,6 +45,10 @@ "types": "./dist/server.d.ts", "import": "./dist/server.js" }, + "./server/identity": { + "types": "./dist/server-identity.d.ts", + "import": "./dist/server-identity.js" + }, "./react": { "types": "./dist/react.d.ts", "import": "./dist/react.js" @@ -67,6 +71,7 @@ "build": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\" && tsup", "dev": "tsup --watch", "test": "vitest run", + "test:bundlers": "node scripts/bundler-smoke.mjs", "typecheck": "tsc --noEmit" }, "peerDependencies": { @@ -90,12 +95,14 @@ "@types/node": "^22.15.0", "@types/react": "^19.1.0", "@types/ws": "^8.18.2", + "esbuild": "^0.28.2", "happy-dom": "^18.0.1", "react": "^19.1.0", "tsup": "^8.5.0", "typescript": "^5.9.2", "vitest": "^3.2.4", "vue": "^3.5.18", + "webpack": "^5.112.0", "ws": "^8.22.0" }, "engines": { diff --git a/packages/dewee-webchat/scripts/bundler-smoke.mjs b/packages/dewee-webchat/scripts/bundler-smoke.mjs new file mode 100644 index 0000000..15ac056 --- /dev/null +++ b/packages/dewee-webchat/scripts/bundler-smoke.mjs @@ -0,0 +1,107 @@ +// Bundler smoke test of the built package (run `pnpm build` first): a host that only serves anonymous +// visitors must be able to bundle `dewee-webchat/server` with webpack and esbuild without +// `@dewee/identity` installed. The SDK is made unresolvable for that bundle, so any reference to it, +// static or dynamic, fails the build. As a control, `dewee-webchat/server/identity` must bundle when +// the SDK is present. +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import * as esbuild from "esbuild"; +import webpack from "webpack"; + +const pkgDir = fileURLToPath(new URL("..", import.meta.url)); +const dist = (f) => join(pkgDir, "dist", f).replace(/\\/g, "/"); +const SDK = "@dewee/identity"; +const work = mkdtempSync(join(tmpdir(), "dewee-webchat-bundlers-")); +const failures = []; + +const anonymousEntry = join(work, "anonymous.mjs"); +writeFileSync( + anonymousEntry, + `import { createDeweeHandler } from ${JSON.stringify(dist("server.js"))};\n` + + `const h = createDeweeHandler({ gatewayUrl: "https://gw.example.com", token: "t", secret: "s".repeat(32), agents: ["support"] });\n` + + `const res = await h(new Request("https://site.test/api/dewee/config", { method: "OPTIONS" }));\n` + + `if (res.status !== 204) throw new Error("unexpected status " + res.status);\n` + + `console.log("anonymous handler ok");\n`, +); +const identityEntry = join(work, "identity.mjs"); +writeFileSync(identityEntry, `import { deweeIdentitySdk } from ${JSON.stringify(dist("server-identity.js"))};\nconsole.log(typeof deweeIdentitySdk.exchangeSession);\n`); + +async function step(name, fn) { + try { + await fn(); + console.log(`ok ${name}`); + } catch (e) { + failures.push(name); + console.error(`FAIL ${name}\n${e?.message ?? e}`); + } +} + +/** esbuild plugin: the SDK is not installed. */ +const sdkAbsent = { + name: "sdk-absent", + setup(build) { + build.onResolve({ filter: /^@dewee\/identity(\/.*)?$/ }, () => ({ errors: [{ text: `${SDK} is not installed` }] })); + }, +}; + +await step("dist/server.js does not import the SDK", () => { + const importOf = /(?:\bfrom\s*|\bimport\s*\(?\s*|\brequire\s*\(\s*)["']@dewee\/identity["']/; + if (importOf.test(readFileSync(dist("server.js"), "utf8"))) throw new Error(`dist/server.js imports ${SDK}`); +}); + +await step("esbuild bundles the anonymous server without the SDK, and the bundle runs", async () => { + const outfile = join(work, "esbuild-anonymous.mjs"); + await esbuild.build({ entryPoints: [anonymousEntry], bundle: true, platform: "node", format: "esm", outfile, plugins: [sdkAbsent], logLevel: "silent" }); + await import(pathToFileURL(outfile).href); +}); + +function runWebpack(entry, filename, alias) { + return new Promise((resolve, reject) => { + webpack( + { + mode: "production", + target: "node", + entry, + experiments: { outputModule: true }, + output: { path: work, filename, module: true, chunkFormat: "module" }, + resolve: { alias }, + optimization: { minimize: false }, + }, + (err, stats) => { + if (err) return reject(err); + if (stats.hasErrors()) return reject(new Error(stats.toString({ all: false, errors: true }))); + resolve(); + }, + ); + }); +} + +await step("webpack bundles the anonymous server without the SDK, and the bundle runs", async () => { + // Aliasing the SDK to a missing file makes any reference to it a "Module not found" error. + await runWebpack(anonymousEntry, "webpack-anonymous.mjs", { [SDK]: join(work, "not-installed", "missing.js") }); + await import(pathToFileURL(join(work, "webpack-anonymous.mjs")).href); +}); + +await step("control: esbuild bundles dewee-webchat/server/identity with the SDK installed", async () => { + await esbuild.build({ entryPoints: [identityEntry], bundle: true, platform: "node", format: "esm", outfile: join(work, "esbuild-identity.mjs"), nodePaths: [join(pkgDir, "node_modules")], logLevel: "silent" }); +}); + +await step("control: esbuild refuses dewee-webchat/server/identity without the SDK", async () => { + let refused = false; + try { + await esbuild.build({ entryPoints: [identityEntry], bundle: true, platform: "node", format: "esm", write: false, plugins: [sdkAbsent], logLevel: "silent" }); + } catch (e) { + refused = String(e?.message ?? e).includes(`${SDK} is not installed`); + if (!refused) throw e; + } + if (!refused) throw new Error("the identity entry bundled without the SDK: the check would not catch a reference"); +}); + +rmSync(work, { recursive: true, force: true }); +if (failures.length) { + console.error(`${failures.length} bundler check(s) failed`); + process.exit(1); +} +console.log("all bundler checks passed"); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9aa2f83..be5cfc6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12,10 +12,10 @@ importers: dependencies: '@astrojs/cloudflare': specifier: ^14.3.4 - version: 14.3.4(@types/node@22.20.5)(astro@7.3.8(@types/node@22.20.5)(aws4fetch@1.0.20)(esbuild@0.28.2)(terser@5.36.0)(yaml@2.9.1))(esbuild@0.28.2)(terser@5.36.0)(wrangler@4.149.0(@cloudflare/workers-types@5.20261010.1)(@types/node@22.20.5))(yaml@2.9.1) + version: 14.3.4(@types/node@22.20.5)(astro@7.3.8(@types/node@22.20.5)(aws4fetch@1.0.20)(esbuild@0.28.2)(terser@5.51.2)(yaml@2.9.1))(esbuild@0.28.2)(terser@5.51.2)(wrangler@4.149.0(@cloudflare/workers-types@5.20261010.1)(@types/node@22.20.5))(yaml@2.9.1) astro: specifier: ^7.3.8 - version: 7.3.8(@types/node@22.20.5)(aws4fetch@1.0.20)(esbuild@0.28.2)(terser@5.36.0)(yaml@2.9.1) + version: 7.3.8(@types/node@22.20.5)(aws4fetch@1.0.20)(esbuild@0.28.2)(terser@5.51.2)(yaml@2.9.1) dewee-webchat: specifier: workspace:* version: link:../../packages/dewee-webchat @@ -100,7 +100,7 @@ importers: devDependencies: '@cloudflare/vite-plugin': specifier: ^1.63.1 - version: 1.63.1(@types/node@22.20.5)(vite@8.3.4(@types/node@22.20.5)(esbuild@0.28.2)(terser@5.36.0)(yaml@2.9.1))(wrangler@4.149.0(@cloudflare/workers-types@5.20261010.1)(@types/node@22.20.5)) + version: 1.63.1(@types/node@22.20.5)(vite@8.3.4(@types/node@22.20.5)(esbuild@0.28.2)(terser@5.51.2)(yaml@2.9.1))(wrangler@4.149.0(@cloudflare/workers-types@5.20261010.1)(@types/node@22.20.5)) '@types/react': specifier: ^19.3.0 version: 19.3.0 @@ -109,13 +109,13 @@ importers: version: 19.3.0(@types/react@19.3.0) '@vitejs/plugin-react': specifier: ^6.1.2 - version: 6.1.2(vite@8.3.4(@types/node@22.20.5)(esbuild@0.28.2)(terser@5.36.0)(yaml@2.9.1)) + version: 6.1.2(vite@8.3.4(@types/node@22.20.5)(esbuild@0.28.2)(terser@5.51.2)(yaml@2.9.1)) typescript: specifier: ^5.9.3 version: 5.9.3 vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@22.20.5)(esbuild@0.28.2)(terser@5.36.0)(yaml@2.9.1) + version: 8.3.4(@types/node@22.20.5)(esbuild@0.28.2)(terser@5.51.2)(yaml@2.9.1) wrangler: specifier: ^4.149.0 version: 4.149.0(@cloudflare/workers-types@5.20261010.1)(@types/node@22.20.5) @@ -131,16 +131,16 @@ importers: devDependencies: '@cloudflare/vite-plugin': specifier: ^1.63.1 - version: 1.63.1(@types/node@22.20.5)(vite@8.3.4(@types/node@22.20.5)(esbuild@0.28.2)(terser@5.36.0)(yaml@2.9.1))(wrangler@4.149.0(@cloudflare/workers-types@5.20261010.1)(@types/node@22.20.5)) + version: 1.63.1(@types/node@22.20.5)(vite@8.3.4(@types/node@22.20.5)(esbuild@0.28.2)(terser@5.51.2)(yaml@2.9.1))(wrangler@4.149.0(@cloudflare/workers-types@5.20261010.1)(@types/node@22.20.5)) '@vitejs/plugin-vue': specifier: ^6.0.10 - version: 6.0.10(vite@8.3.4(@types/node@22.20.5)(esbuild@0.28.2)(terser@5.36.0)(yaml@2.9.1))(vue@3.5.43(typescript@5.9.3)) + version: 6.0.10(vite@8.3.4(@types/node@22.20.5)(esbuild@0.28.2)(terser@5.51.2)(yaml@2.9.1))(vue@3.5.43(typescript@5.9.3)) typescript: specifier: ^5.9.3 version: 5.9.3 vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@22.20.5)(esbuild@0.28.2)(terser@5.36.0)(yaml@2.9.1) + version: 8.3.4(@types/node@22.20.5)(esbuild@0.28.2)(terser@5.51.2)(yaml@2.9.1) vue-tsc: specifier: ^3.3.12 version: 3.3.12(typescript@5.9.3) @@ -162,6 +162,9 @@ importers: '@types/ws': specifier: ^8.18.2 version: 8.18.2 + esbuild: + specifier: ^0.28.2 + version: 0.28.2 happy-dom: specifier: ^18.0.1 version: 18.0.1 @@ -176,10 +179,13 @@ importers: version: 5.9.3 vitest: specifier: ^3.2.4 - version: 3.2.7(@types/node@22.20.5)(happy-dom@18.0.1)(lightningcss@1.33.0)(terser@5.36.0)(yaml@2.9.1) + version: 3.2.7(@types/node@22.20.5)(happy-dom@18.0.1)(lightningcss@1.33.0)(terser@5.51.2)(yaml@2.9.1) vue: specifier: ^3.5.18 version: 3.5.43(typescript@5.9.3) + webpack: + specifier: ^5.112.0 + version: 5.112.0(esbuild@0.28.2)(lightningcss@1.33.0)(postcss@8.5.29) ws: specifier: ^8.22.0 version: 8.22.0 @@ -1705,6 +1711,9 @@ packages: '@types/hast@3.0.5': resolution: {integrity: sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==} + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/mdast@4.0.4': resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} @@ -1834,6 +1843,57 @@ packages: '@vue/shared@3.5.43': resolution: {integrity: sha512-uksS7YGMR5NZyr4JNq0Rp+QyLns0ueaz20KwzIPW9R0LH1Vnt4E+XUM29PNseEbf1www2gOuhuDi5AKOIXag9Q==} + '@webassemblyjs/ast@1.14.1': + resolution: {integrity: sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==} + + '@webassemblyjs/floating-point-hex-parser@1.13.2': + resolution: {integrity: sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==} + + '@webassemblyjs/helper-api-error@1.13.2': + resolution: {integrity: sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==} + + '@webassemblyjs/helper-buffer@1.14.1': + resolution: {integrity: sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==} + + '@webassemblyjs/helper-numbers@1.13.2': + resolution: {integrity: sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA==} + + '@webassemblyjs/helper-wasm-bytecode@1.13.2': + resolution: {integrity: sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==} + + '@webassemblyjs/helper-wasm-section@1.14.1': + resolution: {integrity: sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw==} + + '@webassemblyjs/ieee754@1.13.2': + resolution: {integrity: sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw==} + + '@webassemblyjs/leb128@1.13.2': + resolution: {integrity: sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw==} + + '@webassemblyjs/utf8@1.13.2': + resolution: {integrity: sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ==} + + '@webassemblyjs/wasm-edit@1.14.1': + resolution: {integrity: sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ==} + + '@webassemblyjs/wasm-gen@1.14.1': + resolution: {integrity: sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg==} + + '@webassemblyjs/wasm-opt@1.14.1': + resolution: {integrity: sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw==} + + '@webassemblyjs/wasm-parser@1.14.1': + resolution: {integrity: sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ==} + + '@webassemblyjs/wast-printer@1.14.1': + resolution: {integrity: sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==} + + '@xtuc/ieee754@1.2.0': + resolution: {integrity: sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==} + + '@xtuc/long@4.2.2': + resolution: {integrity: sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==} + abort-controller@3.0.0: resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} engines: {node: '>=6.5'} @@ -1851,6 +1911,22 @@ packages: resolution: {integrity: sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==} engines: {node: '>= 8.0.0'} + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + + ajv-keywords@5.1.0: + resolution: {integrity: sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==} + peerDependencies: + ajv: ^8.8.2 + + ajv@8.20.0: + resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + alien-signals@3.2.1: resolution: {integrity: sha512-I8FjmltrfnDFoZedi5CG8DghVYNhzb/Ijluz7tCSJH0xpd0484Kowhbb1XDYOxfJpU1p5wnM2X54dA+IfGyD1g==} @@ -1931,6 +2007,11 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + baseline-browser-mapping@2.11.30: + resolution: {integrity: sha512-YJs6Pz17RMNv8HoyKREtCY38E3BV1nPkWVmIb+XN1uHC1Vqt3KZCH/uJH9ZKRoiKb/icZ6tvehyee6RZNbFfRA==} + engines: {node: '>=6.0.0'} + hasBin: true + blake3-wasm@2.1.5: resolution: {integrity: sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==} @@ -1951,6 +2032,11 @@ packages: resolution: {integrity: sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==} engines: {node: 20 || >=22} + browserslist@4.29.4: + resolution: {integrity: sha512-2TcbbiwXEq+N0kD36tykpCfyyQigUXmuwNQbnPp4Xk4PX9JOqVIKjJlIDjpH7uY6tmRGoAd+apeT1p60T8wsdA==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} @@ -1979,6 +2065,9 @@ packages: caniuse-lite@1.0.30001815: resolution: {integrity: sha512-uDybUEyfgbyJJ93J1vikTylBX31AkdVYeVPeffMQpf8XN7hSfr0oZOn4VFOIMNvtU5+rHvC5Udg2TgtJKHm23Q==} + caniuse-lite@1.0.30001817: + resolution: {integrity: sha512-uZ1JG80BwhtMalIAGPxPfmNhHyd/B/nTdr3hITWlRamSyAdUStP1Gop01e7SD5q6J90ixJvWDbvmiFVfDgn4bw==} + ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} @@ -2008,6 +2097,10 @@ packages: resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} engines: {node: '>= 20.19.0'} + chrome-trace-event@1.0.4: + resolution: {integrity: sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==} + engines: {node: '>=6.0'} + ci-info@4.4.0: resolution: {integrity: sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==} engines: {node: '>=8'} @@ -2197,6 +2290,9 @@ packages: ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + electron-to-chromium@1.5.461: + resolution: {integrity: sha512-3WZFARz1By+/rYcqQ0OLj4UcEdhd6tz2LEcDtKBUq7ey77EeJ1OtetY/5hjSUxw7dC5r5mA7FfkhRbqO9vnTCg==} + emoji-regex@10.6.0: resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} @@ -2210,6 +2306,10 @@ packages: resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} engines: {node: '>= 0.8'} + enhanced-resolve@5.26.0: + resolution: {integrity: sha512-9vhedylFonb2YGogzUKX6+Ja72gOJbN1QHAqdrvqLwhdl/QWbKopzoUC9EbQNVsAns/bx/4uyqalrQAoy1IByw==} + engines: {node: '>=10.13.0'} + enquirer@2.4.1: resolution: {integrity: sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==} engines: {node: '>=8.6'} @@ -2236,6 +2336,9 @@ packages: es-module-lexer@1.7.0: resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + es-module-lexer@2.3.2: + resolution: {integrity: sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==} + es-module-lexer@3.0.3: resolution: {integrity: sha512-yWGe1O/j+almnV4hOYrmZo8u58JdnmXEMneRQAYkWtqSiT86yoMIEKF4rpqEDj52tbYl3xceBD3DQIBwcsC9Pw==} @@ -2286,6 +2389,10 @@ packages: eventemitter3@5.0.4: resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} + events@3.3.0: + resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} + engines: {node: '>=0.8.x'} + expect-type@1.4.0: resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} engines: {node: '>=12.0.0'} @@ -2297,12 +2404,18 @@ packages: extend@3.0.2: resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + fast-string-truncated-width@3.0.3: resolution: {integrity: sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==} fast-string-width@3.0.2: resolution: {integrity: sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==} + fast-uri@3.1.9: + resolution: {integrity: sha512-hS/0Wt3Ya043oKXs0sIxrMpKQITXhMLIrVAKKXi4gozGC9v7YRWVnXIZzjNPTO02M9BdxLCWqK1yRXgNziVyJQ==} + fast-wrap-ansi@0.2.2: resolution: {integrity: sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==} @@ -2405,6 +2518,9 @@ packages: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + gzip-size@6.0.0: resolution: {integrity: sha512-ax7ZYomf6jqPTQ4+XCpUGyXKHk5WweS+e05MBO4/y3WJ5RkmPXNKvX+bx1behVILVwr6JSQvZAku021CHPXG3Q==} engines: {node: '>=10'} @@ -2416,6 +2532,10 @@ packages: resolution: {integrity: sha512-qn+rKOW7KWpVTtgIUi6RVmTBZJSe2k0Db0vh1f7CWrWclkkc7/Q+FrOfkZIb2eiErLyqu5AXEzE7XthO9JVxRA==} engines: {node: '>=20.0.0'} + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + has-symbols@1.1.0: resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} engines: {node: '>= 0.4'} @@ -2491,6 +2611,10 @@ packages: resolution: {integrity: sha512-N3yCS/NegsOBokc8GAdM8UcmfsKiSS8cipheD/nivzr700H+nsMOxJjQnvwOcRYVuFkdH0wGUvW2WbXGmrZGbQ==} engines: {node: '>=14'} + jest-worker@27.5.1: + resolution: {integrity: sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==} + engines: {node: '>= 10.13.0'} + jose@6.2.13: resolution: {integrity: sha512-7TIg10ZE1x82YediN+lcrPNvGMKge9yFHQD+Qho/b8y2adfugU3AsvvHfxV0fzyFllT6l9xCjf4RWY9g/Z9PJQ==} @@ -2501,6 +2625,9 @@ packages: js-tokens@9.0.1: resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + jsonc-parser@3.3.1: resolution: {integrity: sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==} @@ -2629,6 +2756,9 @@ packages: resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} engines: {node: '>=18'} + merge-stream@2.0.0: + resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} + micromark-util-character@2.1.1: resolution: {integrity: sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==} @@ -2672,6 +2802,61 @@ packages: resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==} engines: {node: '>=16 || 14 >=14.17'} + minimizer-webpack-plugin@5.13.1: + resolution: {integrity: sha512-1mhAfvachWYYOm8tpmany5NLuvP+F2wxZWBSX8DC0uFS2wJpM9q3rWgN9wBooAH9d5V12wX0BjyJe4fMjHMarQ==} + engines: {node: '>= 10.13.0'} + peerDependencies: + '@minify-html/node': '*' + '@napi-rs/image': '*' + '@swc/core': '*' + '@swc/css': '*' + '@swc/html': '*' + clean-css: '*' + cssnano: '*' + csso: '*' + esbuild: '*' + html-minifier-terser: '*' + imagemin: '*' + lightningcss: '*' + postcss: '*' + sharp: '*' + svgo: '*' + uglify-js: '*' + webpack: ^5.1.0 + peerDependenciesMeta: + '@minify-html/node': + optional: true + '@napi-rs/image': + optional: true + '@swc/core': + optional: true + '@swc/css': + optional: true + '@swc/html': + optional: true + clean-css: + optional: true + cssnano: + optional: true + csso: + optional: true + esbuild: + optional: true + html-minifier-terser: + optional: true + imagemin: + optional: true + lightningcss: + optional: true + postcss: + optional: true + sharp: + optional: true + svgo: + optional: true + uglify-js: + optional: true + minipass@7.1.3: resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} engines: {node: '>=16 || 14 >=14.17'} @@ -2757,6 +2942,10 @@ packages: node-mock-http@1.0.5: resolution: {integrity: sha512-KQyt/wLjG3TAc7DOUhpqWzgd4ERxR80JOlTK5VE5R1S12IaPVN5qkj4klBce9HPG1Njuup4Sb5bljaT34lIyjw==} + node-releases@2.0.59: + resolution: {integrity: sha512-5qr2okC4ItAI0hG5SaV0HoYMpnM/Y6+Oqr7AmPYy/2uEoGvS+GbSKLeRxSS1uAdu59yz0ouzcuXievSgg///WA==} + engines: {node: '>=18'} + normalize-path@3.0.0: resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} engines: {node: '>=0.10.0'} @@ -2948,6 +3137,10 @@ packages: regex@6.1.0: resolution: {integrity: sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==} + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + resolve-from@5.0.0: resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} engines: {node: '>=8'} @@ -2985,6 +3178,16 @@ packages: scheduler@0.28.0: resolution: {integrity: sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==} + schema-utils@4.8.1: + resolution: {integrity: sha512-x39pYlJPyH7Uge5QO64B9XGP9zLDc9WNmiQZc+CE3IzZPvMu3lHOuAMGpYNoPixEL7MSWlGhYTg5UlLSL8G6iw==} + engines: {node: '>= 10.13.0'} + hasBin: true + peerDependencies: + webpack: ^5.112.0 + peerDependenciesMeta: + webpack: + optional: true + semver@7.8.5: resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} engines: {node: '>=10'} @@ -3132,16 +3335,29 @@ packages: resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==} engines: {node: '>=18'} + supports-color@8.1.1: + resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} + engines: {node: '>=10'} + svgo@4.1.0: resolution: {integrity: sha512-bkxnTg1kSU0guhIBmibA6UUhrQmPVA1XsQLN+ylCd+UWzbnLkySOcXpyk1mrl05f+pcaCx2eHb+sp6BgMZWX+Q==} engines: {node: '>=16'} hasBin: true + tapable@2.3.3: + resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} + engines: {node: '>=6'} + terser@5.36.0: resolution: {integrity: sha512-IYV9eNMuFAV4THUspIRXkLakHnV6XO7FEdtKjf/mDyrnqUg9LnlOn6/RwRvM9SZjR4GUq8Nk8zj67FzVARr74w==} engines: {node: '>=10'} hasBin: true + terser@5.51.2: + resolution: {integrity: sha512-bWnjSNscmuI+GJze6ZupnHP8G/cTcsJF+bXCeQknk2SHQsgbNJnLrqiH9jZ2W4STPVXH2mDKKRX3iwPhc9Cn/Q==} + engines: {node: '>=10'} + hasBin: true + thenify-all@1.6.0: resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} engines: {node: '>=0.8'} @@ -3353,6 +3569,12 @@ packages: uploadthing: optional: true + update-browserslist-db@1.3.4: + resolution: {integrity: sha512-t8wonIOdQyOjtPd2VktlBu6QjTVTks6v8bSi7kd0r2Vll8TK1JYDZtRRdbmCHOAMphkZgrzK+L+vXK4pyZ8t4A==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + urlpattern-polyfill@10.1.0: resolution: {integrity: sha512-IGjKp/o0NL3Bso1PymYURCJxMPNAf/ILOpendP9f5B6e1rTJgdgiOvgfoT8VxCAdY+Wisb9uhGaJJf3yZ2V9nw==} @@ -3511,6 +3733,10 @@ packages: typescript: optional: true + watchpack@2.6.3: + resolution: {integrity: sha512-s3NZy0QpTY9qGqzc0wtz4qI28z3+5qKbbAec4MV4G9N3CfgZ9wgvQRr0cl2jh/tqkWG1NWHkmVMHZpCamev5Wg==} + engines: {node: '>=10.13.0'} + web-streams-polyfill@4.0.0-beta.3: resolution: {integrity: sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==} engines: {node: '>= 14'} @@ -3518,6 +3744,20 @@ packages: webidl-conversions@3.0.1: resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} + webpack-sources@3.6.0: + resolution: {integrity: sha512-EIMmPVvNI0CYXyRPp34F2Qk7W37/BZVZIofS6LAUnrNjCVahS+hO4mxwFJDjQjODTGZDtVzLYoN2+1dYkhk9qA==} + engines: {node: '>=10.13.0'} + + webpack@5.112.0: + resolution: {integrity: sha512-NIBpsRuCll036haDDt/CfcTDfsWNR9Z782nMRMI9D377BsGcYzZDTNU5TyzhCJzPIcihAxMBaMAhq2WSaDrH8Q==} + engines: {node: '>=10.13.0'} + hasBin: true + peerDependencies: + webpack-cli: '*' + peerDependenciesMeta: + webpack-cli: + optional: true + whatwg-mimetype@3.0.0: resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==} engines: {node: '>=12'} @@ -3678,14 +3918,14 @@ snapshots: '@ast-grep/napi-win32-ia32-msvc': 0.45.3 '@ast-grep/napi-win32-x64-msvc': 0.45.3 - '@astrojs/cloudflare@14.3.4(@types/node@22.20.5)(astro@7.3.8(@types/node@22.20.5)(aws4fetch@1.0.20)(esbuild@0.28.2)(terser@5.36.0)(yaml@2.9.1))(esbuild@0.28.2)(terser@5.36.0)(wrangler@4.149.0(@cloudflare/workers-types@5.20261010.1)(@types/node@22.20.5))(yaml@2.9.1)': + '@astrojs/cloudflare@14.3.4(@types/node@22.20.5)(astro@7.3.8(@types/node@22.20.5)(aws4fetch@1.0.20)(esbuild@0.28.2)(terser@5.51.2)(yaml@2.9.1))(esbuild@0.28.2)(terser@5.51.2)(wrangler@4.149.0(@cloudflare/workers-types@5.20261010.1)(@types/node@22.20.5))(yaml@2.9.1)': dependencies: '@astrojs/internal-helpers': 0.12.0 '@astrojs/underscore-redirects': 1.0.4 - '@cloudflare/vite-plugin': 1.63.1(@types/node@22.20.5)(vite@8.3.4(@types/node@22.20.5)(esbuild@0.28.2)(terser@5.36.0)(yaml@2.9.1))(wrangler@4.149.0(@cloudflare/workers-types@5.20261010.1)(@types/node@22.20.5)) - astro: 7.3.8(@types/node@22.20.5)(aws4fetch@1.0.20)(esbuild@0.28.2)(terser@5.36.0)(yaml@2.9.1) + '@cloudflare/vite-plugin': 1.63.1(@types/node@22.20.5)(vite@8.3.4(@types/node@22.20.5)(esbuild@0.28.2)(terser@5.51.2)(yaml@2.9.1))(wrangler@4.149.0(@cloudflare/workers-types@5.20261010.1)(@types/node@22.20.5)) + astro: 7.3.8(@types/node@22.20.5)(aws4fetch@1.0.20)(esbuild@0.28.2)(terser@5.51.2)(yaml@2.9.1) piccolore: 0.1.3 - vite: 8.3.4(@types/node@22.20.5)(esbuild@0.28.2)(terser@5.36.0)(yaml@2.9.1) + vite: 8.3.4(@types/node@22.20.5)(esbuild@0.28.2)(terser@5.51.2)(yaml@2.9.1) wrangler: 4.149.0(@cloudflare/workers-types@5.20261010.1)(@types/node@22.20.5) transitivePeerDependencies: - '@types/node' @@ -4385,12 +4625,12 @@ snapshots: optionalDependencies: workerd: 1.20261006.1 - '@cloudflare/vite-plugin@1.63.1(@types/node@22.20.5)(vite@8.3.4(@types/node@22.20.5)(esbuild@0.28.2)(terser@5.36.0)(yaml@2.9.1))(wrangler@4.149.0(@cloudflare/workers-types@5.20261010.1)(@types/node@22.20.5))': + '@cloudflare/vite-plugin@1.63.1(@types/node@22.20.5)(vite@8.3.4(@types/node@22.20.5)(esbuild@0.28.2)(terser@5.51.2)(yaml@2.9.1))(wrangler@4.149.0(@cloudflare/workers-types@5.20261010.1)(@types/node@22.20.5))': dependencies: '@cloudflare/unenv-preset': 2.16.2(unenv@2.0.0-rc.24)(workerd@1.20261006.1) miniflare: 5.20261006.1-alpha(@types/node@22.20.5) unenv: 2.0.0-rc.24 - vite: 8.3.4(@types/node@22.20.5)(esbuild@0.28.2)(terser@5.36.0)(yaml@2.9.1) + vite: 8.3.4(@types/node@22.20.5)(esbuild@0.28.2)(terser@5.51.2)(yaml@2.9.1) workerd: 1.20261006.1 wrangler: 4.149.0(@cloudflare/workers-types@5.20261010.1)(@types/node@22.20.5) ws: 8.21.0 @@ -5274,6 +5514,8 @@ snapshots: dependencies: '@types/unist': 3.0.3 + '@types/json-schema@7.0.15': {} + '@types/mdast@4.0.4': dependencies: '@types/unist': 3.0.3 @@ -5317,15 +5559,15 @@ snapshots: '@ungap/structured-clone@1.4.0': {} - '@vitejs/plugin-react@6.1.2(vite@8.3.4(@types/node@22.20.5)(esbuild@0.28.2)(terser@5.36.0)(yaml@2.9.1))': + '@vitejs/plugin-react@6.1.2(vite@8.3.4(@types/node@22.20.5)(esbuild@0.28.2)(terser@5.51.2)(yaml@2.9.1))': dependencies: '@rolldown/pluginutils': 1.0.1 - vite: 8.3.4(@types/node@22.20.5)(esbuild@0.28.2)(terser@5.36.0)(yaml@2.9.1) + vite: 8.3.4(@types/node@22.20.5)(esbuild@0.28.2)(terser@5.51.2)(yaml@2.9.1) - '@vitejs/plugin-vue@6.0.10(vite@8.3.4(@types/node@22.20.5)(esbuild@0.28.2)(terser@5.36.0)(yaml@2.9.1))(vue@3.5.43(typescript@5.9.3))': + '@vitejs/plugin-vue@6.0.10(vite@8.3.4(@types/node@22.20.5)(esbuild@0.28.2)(terser@5.51.2)(yaml@2.9.1))(vue@3.5.43(typescript@5.9.3))': dependencies: '@rolldown/pluginutils': 1.0.1 - vite: 8.3.4(@types/node@22.20.5)(esbuild@0.28.2)(terser@5.36.0)(yaml@2.9.1) + vite: 8.3.4(@types/node@22.20.5)(esbuild@0.28.2)(terser@5.51.2)(yaml@2.9.1) vue: 3.5.43(typescript@5.9.3) '@vitest/expect@3.2.7': @@ -5336,13 +5578,13 @@ snapshots: chai: 5.3.3 tinyrainbow: 2.0.0 - '@vitest/mocker@3.2.7(vite@7.3.7(@types/node@22.20.5)(lightningcss@1.33.0)(terser@5.36.0)(yaml@2.9.1))': + '@vitest/mocker@3.2.7(vite@7.3.7(@types/node@22.20.5)(lightningcss@1.33.0)(terser@5.51.2)(yaml@2.9.1))': dependencies: '@vitest/spy': 3.2.7 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 7.3.7(@types/node@22.20.5)(lightningcss@1.33.0)(terser@5.36.0)(yaml@2.9.1) + vite: 7.3.7(@types/node@22.20.5)(lightningcss@1.33.0)(terser@5.51.2)(yaml@2.9.1) '@vitest/pretty-format@3.2.7': dependencies: @@ -5446,6 +5688,86 @@ snapshots: '@vue/shared@3.5.43': {} + '@webassemblyjs/ast@1.14.1': + dependencies: + '@webassemblyjs/helper-numbers': 1.13.2 + '@webassemblyjs/helper-wasm-bytecode': 1.13.2 + + '@webassemblyjs/floating-point-hex-parser@1.13.2': {} + + '@webassemblyjs/helper-api-error@1.13.2': {} + + '@webassemblyjs/helper-buffer@1.14.1': {} + + '@webassemblyjs/helper-numbers@1.13.2': + dependencies: + '@webassemblyjs/floating-point-hex-parser': 1.13.2 + '@webassemblyjs/helper-api-error': 1.13.2 + '@xtuc/long': 4.2.2 + + '@webassemblyjs/helper-wasm-bytecode@1.13.2': {} + + '@webassemblyjs/helper-wasm-section@1.14.1': + dependencies: + '@webassemblyjs/ast': 1.14.1 + '@webassemblyjs/helper-buffer': 1.14.1 + '@webassemblyjs/helper-wasm-bytecode': 1.13.2 + '@webassemblyjs/wasm-gen': 1.14.1 + + '@webassemblyjs/ieee754@1.13.2': + dependencies: + '@xtuc/ieee754': 1.2.0 + + '@webassemblyjs/leb128@1.13.2': + dependencies: + '@xtuc/long': 4.2.2 + + '@webassemblyjs/utf8@1.13.2': {} + + '@webassemblyjs/wasm-edit@1.14.1': + dependencies: + '@webassemblyjs/ast': 1.14.1 + '@webassemblyjs/helper-buffer': 1.14.1 + '@webassemblyjs/helper-wasm-bytecode': 1.13.2 + '@webassemblyjs/helper-wasm-section': 1.14.1 + '@webassemblyjs/wasm-gen': 1.14.1 + '@webassemblyjs/wasm-opt': 1.14.1 + '@webassemblyjs/wasm-parser': 1.14.1 + '@webassemblyjs/wast-printer': 1.14.1 + + '@webassemblyjs/wasm-gen@1.14.1': + dependencies: + '@webassemblyjs/ast': 1.14.1 + '@webassemblyjs/helper-wasm-bytecode': 1.13.2 + '@webassemblyjs/ieee754': 1.13.2 + '@webassemblyjs/leb128': 1.13.2 + '@webassemblyjs/utf8': 1.13.2 + + '@webassemblyjs/wasm-opt@1.14.1': + dependencies: + '@webassemblyjs/ast': 1.14.1 + '@webassemblyjs/helper-buffer': 1.14.1 + '@webassemblyjs/wasm-gen': 1.14.1 + '@webassemblyjs/wasm-parser': 1.14.1 + + '@webassemblyjs/wasm-parser@1.14.1': + dependencies: + '@webassemblyjs/ast': 1.14.1 + '@webassemblyjs/helper-api-error': 1.13.2 + '@webassemblyjs/helper-wasm-bytecode': 1.13.2 + '@webassemblyjs/ieee754': 1.13.2 + '@webassemblyjs/leb128': 1.13.2 + '@webassemblyjs/utf8': 1.13.2 + + '@webassemblyjs/wast-printer@1.14.1': + dependencies: + '@webassemblyjs/ast': 1.14.1 + '@xtuc/long': 4.2.2 + + '@xtuc/ieee754@1.2.0': {} + + '@xtuc/long@4.2.2': {} + abort-controller@3.0.0: dependencies: event-target-shim: 5.0.1 @@ -5461,6 +5783,22 @@ snapshots: dependencies: humanize-ms: 1.2.1 + ajv-formats@3.0.1(ajv@8.20.0): + optionalDependencies: + ajv: 8.20.0 + + ajv-keywords@5.1.0(ajv@8.20.0): + dependencies: + ajv: 8.20.0 + fast-deep-equal: 3.1.3 + + ajv@8.20.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.9 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + alien-signals@3.2.1: {} am-i-vibing@0.5.0: @@ -5492,7 +5830,7 @@ snapshots: assertion-error@2.0.1: {} - astro@7.3.8(@types/node@22.20.5)(aws4fetch@1.0.20)(esbuild@0.28.2)(terser@5.36.0)(yaml@2.9.1): + astro@7.3.8(@types/node@22.20.5)(aws4fetch@1.0.20)(esbuild@0.28.2)(terser@5.51.2)(yaml@2.9.1): dependencies: '@astrojs/compiler-rs': 0.5.1 '@astrojs/internal-helpers': 0.12.0 @@ -5541,8 +5879,8 @@ snapshots: unifont: 0.7.5 unstorage: 1.17.5(aws4fetch@1.0.20) verkit: 0.5.0 - vite: 8.3.4(@types/node@22.20.5)(esbuild@0.28.2)(terser@5.36.0)(yaml@2.9.1) - vitefu: 1.1.3(vite@8.3.4(@types/node@22.20.5)(esbuild@0.28.2)(terser@5.36.0)(yaml@2.9.1)) + vite: 8.3.4(@types/node@22.20.5)(esbuild@0.28.2)(terser@5.51.2)(yaml@2.9.1) + vitefu: 1.1.3(vite@8.3.4(@types/node@22.20.5)(esbuild@0.28.2)(terser@5.51.2)(yaml@2.9.1)) xxhash-wasm: 1.1.0 yargs-parser: 22.0.0 zod: 4.6.5 @@ -5595,6 +5933,8 @@ snapshots: baseline-browser-mapping@2.11.28: {} + baseline-browser-mapping@2.11.30: {} + blake3-wasm@2.1.5: {} body-parser@2.3.0: @@ -5623,6 +5963,14 @@ snapshots: dependencies: balanced-match: 4.0.4 + browserslist@4.29.4: + dependencies: + baseline-browser-mapping: 2.11.30 + caniuse-lite: 1.0.30001817 + electron-to-chromium: 1.5.461 + node-releases: 2.0.59 + update-browserslist-db: 1.3.4(browserslist@4.29.4) + buffer-from@1.1.2: {} bundle-require@5.1.0(esbuild@0.27.7): @@ -5646,6 +5994,8 @@ snapshots: caniuse-lite@1.0.30001815: {} + caniuse-lite@1.0.30001817: {} + ccount@2.0.1: {} chai@5.3.3: @@ -5672,6 +6022,8 @@ snapshots: dependencies: readdirp: 5.1.1 + chrome-trace-event@1.0.4: {} + ci-info@4.4.0: {} client-only@0.0.1: {} @@ -5833,6 +6185,8 @@ snapshots: ee-first@1.1.1: {} + electron-to-chromium@1.5.461: {} + emoji-regex@10.6.0: {} emoji-regex@8.0.0: {} @@ -5841,6 +6195,11 @@ snapshots: encodeurl@2.0.0: {} + enhanced-resolve@5.26.0: + dependencies: + graceful-fs: 4.2.11 + tapable: 2.3.3 + enquirer@2.4.1: dependencies: ansi-colors: 4.1.3 @@ -5858,6 +6217,8 @@ snapshots: es-module-lexer@1.7.0: {} + es-module-lexer@2.3.2: {} + es-module-lexer@3.0.3: {} es-object-atoms@1.1.2: @@ -5947,6 +6308,8 @@ snapshots: eventemitter3@5.0.4: {} + events@3.3.0: {} + expect-type@1.4.0: {} express@5.3.0: @@ -5984,12 +6347,16 @@ snapshots: extend@3.0.2: {} + fast-deep-equal@3.1.3: {} + fast-string-truncated-width@3.0.3: {} fast-string-width@3.0.2: dependencies: fast-string-truncated-width: 3.0.3 + fast-uri@3.1.9: {} + fast-wrap-ansi@0.2.2: dependencies: fast-string-width: 3.0.2 @@ -6100,6 +6467,8 @@ snapshots: gopd@1.2.0: {} + graceful-fs@4.2.11: {} + gzip-size@6.0.0: dependencies: duplexer: 0.1.2 @@ -6122,6 +6491,8 @@ snapshots: '@types/whatwg-mimetype': 3.0.2 whatwg-mimetype: 3.0.0 + has-flag@4.0.0: {} + has-symbols@1.1.0: {} has-tostringtag@1.0.2: @@ -6196,12 +6567,20 @@ snapshots: optionalDependencies: '@pkgjs/parseargs': 0.11.0 + jest-worker@27.5.1: + dependencies: + '@types/node': 22.20.5 + merge-stream: 2.0.0 + supports-color: 8.1.1 + jose@6.2.13: {} joycon@3.1.1: {} js-tokens@9.0.1: {} + json-schema-traverse@1.0.0: {} + jsonc-parser@3.3.1: {} kleur@4.1.5: {} @@ -6303,6 +6682,8 @@ snapshots: merge-descriptors@2.0.0: {} + merge-stream@2.0.0: {} + micromark-util-character@2.1.1: dependencies: micromark-util-symbol: 2.0.1 @@ -6353,6 +6734,18 @@ snapshots: dependencies: brace-expansion: 2.1.7 + minimizer-webpack-plugin@5.13.1(esbuild@0.28.2)(lightningcss@1.33.0)(postcss@8.5.29)(webpack@5.112.0(esbuild@0.28.2)(lightningcss@1.33.0)(postcss@8.5.29)): + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + jest-worker: 27.5.1 + schema-utils: 4.8.1(webpack@5.112.0(esbuild@0.28.2)(lightningcss@1.33.0)(postcss@8.5.29)) + terser: 5.51.2 + webpack: 5.112.0(esbuild@0.28.2)(lightningcss@1.33.0)(postcss@8.5.29) + optionalDependencies: + esbuild: 0.28.2 + lightningcss: 1.33.0 + postcss: 8.5.29 + minipass@7.1.3: {} mkdirp@3.0.1: {} @@ -6427,6 +6820,8 @@ snapshots: node-mock-http@1.0.5: {} + node-releases@2.0.59: {} + normalize-path@3.0.0: {} nth-check@2.1.1: @@ -6585,6 +6980,8 @@ snapshots: dependencies: regex-utilities: 2.3.0 + require-from-string@2.0.2: {} + resolve-from@5.0.0: {} resolve-pkg-maps@1.0.0: {} @@ -6681,6 +7078,15 @@ snapshots: scheduler@0.28.0: {} + schema-utils@4.8.1(webpack@5.112.0(esbuild@0.28.2)(lightningcss@1.33.0)(postcss@8.5.29)): + dependencies: + '@types/json-schema': 7.0.15 + ajv: 8.20.0 + ajv-formats: 3.0.1(ajv@8.20.0) + ajv-keywords: 5.1.0(ajv@8.20.0) + optionalDependencies: + webpack: 5.112.0(esbuild@0.28.2)(lightningcss@1.33.0)(postcss@8.5.29) + semver@7.8.5: {} send@1.2.1: @@ -6872,6 +7278,10 @@ snapshots: supports-color@10.2.2: {} + supports-color@8.1.1: + dependencies: + has-flag: 4.0.0 + svgo@4.1.0: dependencies: commander: 11.1.0 @@ -6882,6 +7292,8 @@ snapshots: picocolors: 1.1.1 sax: 1.6.1 + tapable@2.3.3: {} + terser@5.36.0: dependencies: '@jridgewell/source-map': 0.3.11 @@ -6889,6 +7301,13 @@ snapshots: commander: 2.20.3 source-map-support: 0.5.21 + terser@5.51.2: + dependencies: + '@jridgewell/source-map': 0.3.11 + acorn: 8.19.0 + commander: 2.20.3 + source-map-support: 0.5.21 + thenify-all@1.6.0: dependencies: thenify: 3.3.1 @@ -7044,6 +7463,12 @@ snapshots: optionalDependencies: aws4fetch: 1.0.20 + update-browserslist-db@1.3.4(browserslist@4.29.4): + dependencies: + browserslist: 4.29.4 + escalade: 3.2.0 + picocolors: 1.1.1 + urlpattern-polyfill@10.1.0: {} vary@1.1.2: {} @@ -7060,13 +7485,13 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 - vite-node@3.2.4(@types/node@22.20.5)(lightningcss@1.33.0)(terser@5.36.0)(yaml@2.9.1): + vite-node@3.2.4(@types/node@22.20.5)(lightningcss@1.33.0)(terser@5.51.2)(yaml@2.9.1): dependencies: cac: 6.7.14 debug: 4.4.3 es-module-lexer: 1.7.0 pathe: 2.0.3 - vite: 7.3.7(@types/node@22.20.5)(lightningcss@1.33.0)(terser@5.36.0)(yaml@2.9.1) + vite: 7.3.7(@types/node@22.20.5)(lightningcss@1.33.0)(terser@5.51.2)(yaml@2.9.1) transitivePeerDependencies: - '@types/node' - jiti @@ -7081,7 +7506,7 @@ snapshots: - tsx - yaml - vite@7.3.7(@types/node@22.20.5)(lightningcss@1.33.0)(terser@5.36.0)(yaml@2.9.1): + vite@7.3.7(@types/node@22.20.5)(lightningcss@1.33.0)(terser@5.51.2)(yaml@2.9.1): dependencies: esbuild: 0.28.2 fdir: 6.5.0(picomatch@4.0.7) @@ -7093,10 +7518,10 @@ snapshots: '@types/node': 22.20.5 fsevents: 2.3.3 lightningcss: 1.33.0 - terser: 5.36.0 + terser: 5.51.2 yaml: 2.9.1 - vite@8.3.4(@types/node@22.20.5)(esbuild@0.28.2)(terser@5.36.0)(yaml@2.9.1): + vite@8.3.4(@types/node@22.20.5)(esbuild@0.28.2)(terser@5.51.2)(yaml@2.9.1): dependencies: lightningcss: 1.33.0 picomatch: 4.0.7 @@ -7107,18 +7532,18 @@ snapshots: '@types/node': 22.20.5 esbuild: 0.28.2 fsevents: 2.3.3 - terser: 5.36.0 + terser: 5.51.2 yaml: 2.9.1 - vitefu@1.1.3(vite@8.3.4(@types/node@22.20.5)(esbuild@0.28.2)(terser@5.36.0)(yaml@2.9.1)): + vitefu@1.1.3(vite@8.3.4(@types/node@22.20.5)(esbuild@0.28.2)(terser@5.51.2)(yaml@2.9.1)): optionalDependencies: - vite: 8.3.4(@types/node@22.20.5)(esbuild@0.28.2)(terser@5.36.0)(yaml@2.9.1) + vite: 8.3.4(@types/node@22.20.5)(esbuild@0.28.2)(terser@5.51.2)(yaml@2.9.1) - vitest@3.2.7(@types/node@22.20.5)(happy-dom@18.0.1)(lightningcss@1.33.0)(terser@5.36.0)(yaml@2.9.1): + vitest@3.2.7(@types/node@22.20.5)(happy-dom@18.0.1)(lightningcss@1.33.0)(terser@5.51.2)(yaml@2.9.1): dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.7 - '@vitest/mocker': 3.2.7(vite@7.3.7(@types/node@22.20.5)(lightningcss@1.33.0)(terser@5.36.0)(yaml@2.9.1)) + '@vitest/mocker': 3.2.7(vite@7.3.7(@types/node@22.20.5)(lightningcss@1.33.0)(terser@5.51.2)(yaml@2.9.1)) '@vitest/pretty-format': 3.2.7 '@vitest/runner': 3.2.7 '@vitest/snapshot': 3.2.7 @@ -7136,8 +7561,8 @@ snapshots: tinyglobby: 0.2.17 tinypool: 1.1.1 tinyrainbow: 2.0.0 - vite: 7.3.7(@types/node@22.20.5)(lightningcss@1.33.0)(terser@5.36.0)(yaml@2.9.1) - vite-node: 3.2.4(@types/node@22.20.5)(lightningcss@1.33.0)(terser@5.36.0)(yaml@2.9.1) + vite: 7.3.7(@types/node@22.20.5)(lightningcss@1.33.0)(terser@5.51.2)(yaml@2.9.1) + vite-node: 3.2.4(@types/node@22.20.5)(lightningcss@1.33.0)(terser@5.51.2)(yaml@2.9.1) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 22.20.5 @@ -7174,10 +7599,53 @@ snapshots: optionalDependencies: typescript: 5.9.3 + watchpack@2.6.3: + dependencies: + graceful-fs: 4.2.11 + web-streams-polyfill@4.0.0-beta.3: {} webidl-conversions@3.0.1: {} + webpack-sources@3.6.0: {} + + webpack@5.112.0(esbuild@0.28.2)(lightningcss@1.33.0)(postcss@8.5.29): + dependencies: + '@types/estree': 1.0.9 + '@types/json-schema': 7.0.15 + '@webassemblyjs/ast': 1.14.1 + '@webassemblyjs/wasm-edit': 1.14.1 + '@webassemblyjs/wasm-parser': 1.14.1 + browserslist: 4.29.4 + chrome-trace-event: 1.0.4 + enhanced-resolve: 5.26.0 + es-module-lexer: 2.3.2 + events: 3.3.0 + graceful-fs: 4.2.11 + mime-db: 1.54.0 + minimizer-webpack-plugin: 5.13.1(esbuild@0.28.2)(lightningcss@1.33.0)(postcss@8.5.29)(webpack@5.112.0(esbuild@0.28.2)(lightningcss@1.33.0)(postcss@8.5.29)) + schema-utils: 4.8.1(webpack@5.112.0(esbuild@0.28.2)(lightningcss@1.33.0)(postcss@8.5.29)) + tapable: 2.3.3 + watchpack: 2.6.3 + webpack-sources: 3.6.0 + transitivePeerDependencies: + - '@minify-html/node' + - '@napi-rs/image' + - '@swc/core' + - '@swc/css' + - '@swc/html' + - clean-css + - cssnano + - csso + - esbuild + - html-minifier-terser + - imagemin + - lightningcss + - postcss + - sharp + - svgo + - uglify-js + whatwg-mimetype@3.0.0: {} whatwg-url@5.0.0: From 158939866a52396f790a01d48e5b79b4d2f84299 Mon Sep 17 00:00:00 2001 From: Duy Nguyen Date: Tue, 20 Oct 2026 17:48:45 +0700 Subject: [PATCH 6/7] docs: document sealed identity sessions, rebinding, reuse and rate limits Co-Authored-By: Claude Opus 5.5 --- docs/http-contract.md | 77 ++++++++++++++++++++++++-------- packages/dewee-webchat/README.md | 67 +++++++++++++++++++++------ 2 files changed, 112 insertions(+), 32 deletions(-) diff --git a/docs/http-contract.md b/docs/http-contract.md index 2c4d8ab..5b1172b 100644 --- a/docs/http-contract.md +++ b/docs/http-contract.md @@ -128,23 +128,51 @@ signed-in session in memory only. **`POST {base}/session`** first asks the host who the visitor is (server side, from its own login): - Signed in: the backend exchanges the user for a Dewee identity session - (`POST {gateway}/v1/identity/sessions/exchange` with a signed partner assertion) and answers - `{ "visitorId", "token", "expiresAt", "verified": true }`. The visitor token carries two more claims, - `ids` (the Dewee identity session token: one agent, chat only, short-lived) and `ida` (its agent): - `base64url(JSON{"v", "exp", "ids", "ida"})`. `exp` is the earlier of the identity session expiry and - the visitor session lifetime. `visitorId` is derived from the Dewee principal, so the same user keeps - the same conversations everywhere. If the exchange fails, no token is issued. -- Not signed in: an ordinary anonymous visitor token, unless the backend requires sign-in, in which case - the answer is `401 IDENTITY_REQUIRED` (or `503 SOURCE_UNAVAILABLE` when the host's login check failed). - -The gateway token, the partner assertion secret and the private key never appear in any response. + (`POST {gateway}/v1/identity/sessions/exchange` with a signed partner assertion), or reuses the one it + holds for that user until it nears expiry, and answers + `{ "visitorId", "token", "expiresAt", "verified": true, "expiresIn" }`. `expiresIn` is the + milliseconds the session stays valid, counted from receipt; the widget refreshes + `min(60 s, expiresIn/2)` before the end. The visitor token carries two more claims, `ids` and `ida` + (the bound agent): `base64url(JSON{"v", "exp", "ids", "ida"})`. `ids` is opaque: + `base64url(iv ‖ AES-256-GCM(key, JSON{"t": , "h": }))`, the key + derived from the backend secret with HKDF-SHA-256 (info `dewee-webchat:identity-session:aes-256-gcm:v1`) + and the associated data binding it to `v` and `ida`. `h` is an HMAC of the host's subject, never the + subject itself. `exp` is the earlier of the identity session expiry (measured from receipt on the + backend's clock) and the visitor session lifetime. `visitorId` is derived from the Dewee principal, so + the same user keeps the same conversations everywhere. If the exchange fails, no token is issued. +- Not signed in: an anonymous visitor token, unless the backend requires sign-in, in which case the + answer is `401 IDENTITY_REQUIRED`. A still-valid anonymous `token` in the body (more than a day left) + is returned as is. +- The host's login check failed (`authenticate` threw): `503 SOURCE_UNAVAILABLE`, never an anonymous + token. + +The gateway token, the partner assertion secret, the private key, the raw identity session and the +host's subject never appear in any response. The identity session is a bearer credential for the user +until it expires or is revoked: chat and history with the bound agent, starting web-first links, and +listing and removing the user's own linked chat accounts (`/v1/identity/me/bindings`). + +A widget on another origin sends no cookies of the backend's site, so a cookie-based login check only +works same-origin. **`POST {base}/chat`, `GET {base}/history`** with a token that carries `ids`: the backend connects with -`{"token": …, "identity_session": , "tenant_id"?: …}` and no `user_id`; the gateway derives the user -(`tuc:`). The request is bound to the token's user whatever cookies it carries, and only to -the `ida` agent (another `agent` is `403 agent_not_allowed`). The connection is checked before the stream -starts, so a refused session is a JSON error, not an SSE event. A token with `ids` is refused (401) by a -backend that does not sign users in. +`{"token": …, "identity_session": , "tenant_id"?: …}` and no `user_id`; the gateway +derives the user. The backend goes on only when the connect answer's `user_id` is `tuc:`; +anything else (a gateway that ignored the session) is `503 SOURCE_UNAVAILABLE` and nothing runs. The +request is bound to the `ida` agent, which must still be allowed (another `agent`, or an `ida` no longer +in the allowlist, is `403 agent_not_allowed`). The connection is checked before the stream starts, so a +refused session is a JSON error, not an SSE event. A token with `ids` is refused (401) by a backend +that does not sign users in. + +By default the request is bound to the token's user whatever cookies it carries. A backend with +**per-request rebinding** (`rebindEachRequest`, recommended with cookie logins) checks the host's login +again on `/chat`, `/history` and `/link`: a different user or none is `401 IDENTITY_REQUIRED` (the widget +asks `/session` again), a failed check `503 SOURCE_UNAVAILABLE`. A widget token replayed under another +user's cookie is then useless. + +**Rate limits** (the Node/Workers defaults): signed-in `/session` 30 per user per 10 minutes, `/link` 5 +per user per 10 minutes, new anonymous sessions 10 per IP per 10 minutes, messages 20 per minute. The IP +comes from `cf-connecting-ip`, `x-real-ip` or `x-forwarded-for`, which only a proxy in front can vouch +for (unchanged from before signed-in users). **Identity errors.** The gateway's typed errors are forwarded with their remediation: @@ -161,12 +189,16 @@ backend that does not sign users in. | `IDENTITY_LINK_REQUIRED` | 403 | The chat account is not linked yet | | `PERMISSION_DENIED` | 403 | Verified but not allowed (for example another agent) | | `IDENTITY_CONFLICT` | 409 | The account is linked to another user | -| `SOURCE_UNAVAILABLE` | 503 | Identity could not be checked; nothing ran | +| `SOURCE_UNAVAILABLE` | 503 | Identity could not be checked (host login check failed, gateway did not apply the session); nothing ran | An identity error raised during a run arrives as the SSE event `{"type": "error", "message", "code", "remediation"}`. The widget shows its own localized text for -each code and, after `LINK_EXPIRED` or `LINK_REVOKED`, requests `/session` again. Other exchange -failures are `502 gateway_error`, `429 rate_limited` or `500 identity_misconfigured`, without detail. +each code and, after `LINK_EXPIRED` or `LINK_REVOKED`, requests `/session` again. + +On `/session` (the exchange), only errors whose remediation is for the `end_user` are forwarded typed. +One addressed to the `operator` or `integrator` (the application or agent set up wrong), or an exchange +route switched off on the gateway, is `500 identity_misconfigured` without detail; the reason goes to +the backend's log. Other exchange failures are `502 gateway_error` or `429 rate_limited`, without detail. ### `POST {base}/link` @@ -181,7 +213,14 @@ backend calls `POST {gateway}/v1/identity/links` with the identity session and a The user sends `proofCode` in a direct message from the chat account to link; Dewee checks both sides and links on its own. Nobody types an account id. A visitor without an identity session gets -`401 IDENTITY_REQUIRED`; a backend that does not sign users in answers `404`. +`401 IDENTITY_REQUIRED`; a backend that does not sign users in answers `404`. Other answers: + +| `code` | Status | | +|---|---|---| +| `link_in_progress` | 409 | A link is already in progress; use its code or wait until it expires | +| `idempotency_key_reused` | 409 | The `idempotencyKey` was already used for a different request | +| `linking_unavailable` | 503 | The gateway has linking switched off | +| `LINK_EXPIRED`, `LINK_REVOKED` | 401 | The widget renews the session once and retries | ## `GET {base}/history?agent=…&conversationId=…` diff --git a/packages/dewee-webchat/README.md b/packages/dewee-webchat/README.md index 3655a0b..ba18721 100644 --- a/packages/dewee-webchat/README.md +++ b/packages/dewee-webchat/README.md @@ -205,15 +205,18 @@ session, and the gateway derives the user from it. ```ts import { createDeweeHandler, optionsFromEnv } from "dewee-webchat/server"; +import { deweeIdentitySdk } from "dewee-webchat/server/identity"; const handler = createDeweeHandler({ ...optionsFromEnv(process.env), identity: { + sdk: deweeIdentitySdk, // Your own login. Return null for a visitor who is not signed in. authenticate: async (req) => { const user = await getUserFromSessionCookie(req.headers.get("cookie")); return user ? { subject: user.id } : null; // your stable id, never an email address }, + rebindEachRequest: true, // recommended with a cookie login, see below appId: process.env.DEWEE_IDENTITY_APP_ID!, issuer: process.env.DEWEE_IDENTITY_ISSUER!, assertionSecret: process.env.DEWEE_IDENTITY_ASSERTION_SECRET!, // or privateKeyJwk for a jwks application @@ -223,33 +226,60 @@ const handler = createDeweeHandler({ Setup: create the application once with `dewee identity setup --name --issuer ` (it prints the application id and the assertion secret), and install the server SDK next to this package: -`npm install @dewee/identity`. It is an optional peer dependency, loaded only when `identity` is set. +`npm install @dewee/identity`. It is an optional peer dependency used only by the +`dewee-webchat/server/identity` entry; `dewee-webchat/server` itself never imports it, so a site that +serves only anonymous visitors bundles fine (webpack, esbuild…) without it. `@dewee/identity` is not on npm yet: until it is, build its tarball from the [dewee repository](https://github.com/nextlevelbuilder/dewee/blob/dev/packages/dewee-identity/README.md) (`pnpm --filter @dewee/identity pack`) and install that file. | `identity` option | | |---|---| -| `authenticate(req)` | Required. Resolves `{ subject }` for a signed-in user, `null` otherwise. | +| `sdk` | Required. `deweeIdentitySdk` from `dewee-webchat/server/identity`. | +| `authenticate(req)` | Required. Resolves `{ subject }` for a signed-in user, `null` otherwise. Throwing means "could not check". | | `appId`, `issuer` | Required. The Dewee application id and its issuer, exactly. | | `assertionSecret` \| `privateKeyJwk` (+ `algorithm`) | Exactly one: `hmac` or `jwks` application. Server only. | | `audience` | Optional check; must be `dewee:`. | | `agent` | The agent sessions are bound to. Default: `defaultAgent`, else the first of `agents`. | | `required` | Default `false`. When `true`, visitors who are not signed in get no session at all. | +| `rebindEachRequest` | Default `false`. Check the signed-in user again on every `/chat`, `/history` and `/link`. | | `sessionTtlSeconds` | 60 to 43200. Default: the gateway default (3600). | | `allowInsecureHttp` | Allow plain http to a gateway that is not loopback (private networks only). | +`AuthenticatedUser` has no scopes: the session exchange does not carry any. When a user's permissions +change, report it from your server with `lifecycle({ event: "scopes_changed", … })` from +`@dewee/identity`. + How it behaves: -- `POST /session` calls `authenticate`. For a user it calls `exchangeSession` and returns a visitor token - that carries the Dewee session (one agent, chat only, short-lived; it expires with the session). The - gateway token, the assertion secret and the private key never reach the browser. -- `/chat` and `/history` connect to the gateway with that `identity_session` and no `user_id`. A - visitor token for user A keeps chatting as A even if a later request carries B's cookie; a token - that was altered is refused (401). The same user gets the same conversations on every device. -- Fail closed. If `authenticate` returns `null` or throws, the visitor chats anonymously, exactly as - without `identity`, unless `required` is set, in which case no session is issued. If the exchange - for a signed-in user fails, no session is issued: a user is never silently downgraded. +- `POST /session` calls `authenticate`. For a user it returns a visitor token that carries a Dewee + identity session, encrypted (AES-256-GCM, key derived from `secret`) so the browser only holds an + opaque value. The gateway token, the assertion secret, the private key, the raw identity session and + your subject never reach the browser. +- One identity session per user is kept in memory and reused until it nears expiry, so a page load + does not cost an exchange; a session the gateway reports expired or revoked is dropped. +- `/chat` and `/history` connect to the gateway with that `identity_session` and no `user_id`, and only + go on when the gateway confirms it derived a Dewee user (`tuc:…`); a gateway that ignored the session + (identity switched off) gets `503 SOURCE_UNAVAILABLE` and nothing runs. The same user gets the same + conversations on every device. The session's agent must still be in `agents` on every request. +- By default the visitor token is the credential: a token for user A keeps chatting as A even if a + later request carries B's cookie, and an altered token is refused (401). With + `rebindEachRequest: true`, `/chat`, `/history` and `/link` call `authenticate(req)` again and compare + it with a keyed digest of the subject sealed in the token: another user or no user is + `401 IDENTITY_REQUIRED` (the widget then asks `/session` again), and a throwing `authenticate` is + `503 SOURCE_UNAVAILABLE`. Turn it on when your login is a cookie: a widget token replayed under + another user's cookie is then useless. +- Fail closed. If `authenticate` returns `null`, the visitor chats anonymously, exactly as without + `identity`, unless `required` is set, in which case no session is issued. If it throws, the answer + is `503 SOURCE_UNAVAILABLE` and no session is issued. If the exchange for a signed-in user fails, no + session is issued: a user is never silently downgraded. An exchange refusal meant for the operator + (an application or agent set up wrong) is `500 identity_misconfigured`, with the reason in your logs. +- What the identity session allows, until it expires or is revoked: chatting with the bound agent and + reading that chat's history as the user, starting web-first links, and listing and removing the + user's own linked chat accounts (`/v1/identity/me/bindings`). That is why it never reaches the + browser in the clear. +- `authenticate` sees the request the widget sends. A widget on another origin (`allowedOrigins`) + sends no cookies of your site, so a cookie-based `authenticate` only works same-origin. - `DEWEE_GATEWAY_TOKEN` must be a tenant API key without an owner: the gateway refuses an identity session next to a key bound to a user (`IDENTITY_CONFLICT`). - `identity` cannot be combined with `allowClientCredentials` (the handler refuses to start). Sessions @@ -259,13 +289,21 @@ How it behaves: `{ error: { code, remediation } }`. The widget shows its own localized message and, on `LINK_EXPIRED` or `LINK_REVOKED`, asks `/session` again. - The widget keeps a signed-in session in memory only, so each page load asks your backend who the - visitor is. Call `chat.renewSession()` after a sign-in or sign-out without a page load. + visitor is. It refreshes the session `min(60 s, lifetime/2)` before it ends, counting the lifetime + (`expiresIn`) from when it received it. Call `chat.renewSession()` after a sign-in or sign-out + without a page load. +- Rate limits (default, in memory): 30 signed-in `/session` calls per user per 10 minutes, 5 link starts + per user per 10 minutes. A visitor who is not signed in and still has a valid anonymous token gets it + back as is; only minting a new one counts against the 10 per IP per 10 minutes. A custom `rateLimit` + receives the buckets `verified` and `link` besides `session` and `chat`. **Linking a chat app.** A signed-in user can link their Telegram, Discord or other chat account without typing any id: call `chat.startLink()`. The widget asks `POST {endpoint}/link`, which starts a Dewee web-first link, and shows the proof code with "Continue on Telegram" for each chat account the code can be sent to. The user sends the code in a direct message; Dewee checks both sides and -links on its own. `dewee:link` fires with `{ expiresAt, deepLinks }`. +links on its own. `dewee:link` fires with `{ expiresAt, deepLinks }`. A link that is already in +progress answers `409 link_in_progress`, a reused idempotency key `409 idempotency_key_reused`, and a +gateway with linking switched off `503 linking_unavailable`; the widget shows its own text for each. The PHP backend does not support signed-in users yet (it has no JWS signing); use the Node/Workers handler. @@ -277,6 +315,9 @@ The PHP backend does not support signed-in users yet (it has no JWS signing); us - Only allowlisted agents are reachable; origin allowlist, rate limits (20 messages/min per visitor, 10 new sessions/10 min per IP by default — pass `rateLimit` for a shared store), 4000-char messages, optional Turnstile. +- The client IP for per-IP limits comes from `cf-connecting-ip`, `x-real-ip` or the first + `x-forwarded-for` entry. These headers are only trustworthy behind a proxy that sets them (as + before signed-in users existed); elsewhere a client can choose its own IP. - "Bring your own gateway" (`allowClientCredentials`) is off by default; when on, internal hosts are refused. The wire format is documented in [docs/http-contract.md](https://github.com/nextlevelbuilder/dewee-webchat/blob/main/docs/http-contract.md). From 323bdee3ccd5aad7eb91c6714975e477c47e733a Mon Sep 17 00:00:00 2001 From: Duy Nguyen Date: Tue, 20 Oct 2026 17:57:51 +0700 Subject: [PATCH 7/7] fix(server): constant-time subject compare, pre-auth rate limit, sealed claim size - Compare the rebinding subject digest with a constant-time byte compare. - Cap host authenticate calls per IP (new preauth bucket, default 120 per minute) before authenticate runs on /session and on rebinding; document that authenticate should be cheap or cached. - Size the sealed identity claim cap from the real plaintext layout ({"t":...,"h":...} with worst-case JSON escaping and the subject digest), so a 512-character session token always seals and opens. Co-Authored-By: Claude Opus 5.5 --- docs/http-contract.md | 7 +++- packages/dewee-webchat/README.md | 12 ++++-- packages/dewee-webchat/src/server/handler.ts | 24 +++++++++--- packages/dewee-webchat/src/server/security.ts | 36 +++++++++++++---- packages/dewee-webchat/test/identity.test.ts | 39 +++++++++++++++++-- 5 files changed, 97 insertions(+), 21 deletions(-) diff --git a/docs/http-contract.md b/docs/http-contract.md index 5b1172b..14873f6 100644 --- a/docs/http-contract.md +++ b/docs/http-contract.md @@ -169,8 +169,11 @@ again on `/chat`, `/history` and `/link`: a different user or none is `401 IDENT asks `/session` again), a failed check `503 SOURCE_UNAVAILABLE`. A widget token replayed under another user's cookie is then useless. -**Rate limits** (the Node/Workers defaults): signed-in `/session` 30 per user per 10 minutes, `/link` 5 -per user per 10 minutes, new anonymous sessions 10 per IP per 10 minutes, messages 20 per minute. The IP +**Rate limits** (the Node/Workers defaults): host login checks 120 per IP per minute (before the check +runs, on `/session` and on rebinding), signed-in `/session` 30 per user per 10 minutes, `/link` 5 per +user per 10 minutes, new anonymous sessions 10 per IP per 10 minutes, messages 20 per minute. Every +limit answers `429 rate_limited`. The host's login check runs on every `/session` (every page load) and, +with rebinding, on every `/chat`, `/history` and `/link`, so it should be cheap or cached. The IP comes from `cf-connecting-ip`, `x-real-ip` or `x-forwarded-for`, which only a proxy in front can vouch for (unchanged from before signed-in users). diff --git a/packages/dewee-webchat/README.md b/packages/dewee-webchat/README.md index ba18721..b1be327 100644 --- a/packages/dewee-webchat/README.md +++ b/packages/dewee-webchat/README.md @@ -292,10 +292,14 @@ How it behaves: visitor is. It refreshes the session `min(60 s, lifetime/2)` before it ends, counting the lifetime (`expiresIn`) from when it received it. Call `chat.renewSession()` after a sign-in or sign-out without a page load. -- Rate limits (default, in memory): 30 signed-in `/session` calls per user per 10 minutes, 5 link starts - per user per 10 minutes. A visitor who is not signed in and still has a valid anonymous token gets it - back as is; only minting a new one counts against the 10 per IP per 10 minutes. A custom `rateLimit` - receives the buckets `verified` and `link` besides `session` and `chat`. +- Rate limits (default, in memory): 120 `authenticate` calls per IP per minute (checked before + `authenticate` runs), 30 signed-in `/session` calls per user per 10 minutes, 5 link starts per user per + 10 minutes. A visitor who is not signed in and still has a valid anonymous token gets it back as is; + only minting a new one counts against the 10 per IP per 10 minutes. A custom `rateLimit` receives the + buckets `preauth`, `verified` and `link` besides `session` and `chat`. +- Keep `authenticate` cheap or cached (a signed-cookie check, not a database round trip per call): it + runs on every `/session`, so on every page load, and with `rebindEachRequest` on every `/chat`, + `/history` and `/link`, with only the generous per-IP cap in front of it. **Linking a chat app.** A signed-in user can link their Telegram, Discord or other chat account without typing any id: call `chat.startLink()`. The widget asks `POST {endpoint}/link`, which starts diff --git a/packages/dewee-webchat/src/server/handler.ts b/packages/dewee-webchat/src/server/handler.ts index ae945c9..1d71c69 100644 --- a/packages/dewee-webchat/src/server/handler.ts +++ b/packages/dewee-webchat/src/server/handler.ts @@ -32,6 +32,7 @@ import { memoryRateLimiter, randomId, signVisitorToken, + constantTimeEqual, subjectDigest, verifyTurnstile, verifyVisitorToken, @@ -96,7 +97,8 @@ export interface DeweeIdentityOptions { /** * Reads your own login (cookie, header…). Return null for a visitor who is not signed in. Throwing * means the login could not be read: the visitor gets `503 SOURCE_UNAVAILABLE`, never an anonymous - * session. + * session. Keep it cheap or cached: it runs on every `/session` (every page load) and, with + * `rebindEachRequest`, on every `/chat`, `/history` and `/link`, behind a generous per-IP cap only. */ authenticate(req: Request): Promise; /** Dewee application id (`dewee identity setup` prints it). */ @@ -156,8 +158,8 @@ export interface DeweeHandlerOptions { turnstile?: { siteKey: string; secretKey: string }; /** * Custom limiter, or false to disable. Default (in memory): 20 messages/min per visitor and IP, - * 10 new sessions/10 min per IP and, with `identity`, 30 signed-in sessions/10 min per user and - * 5 link starts/10 min per user. + * 10 new sessions/10 min per IP and, with `identity`, 120 `authenticate` calls/min per IP, + * 30 signed-in sessions/10 min per user and 5 link starts/10 min per user. */ rateLimit?: RateLimiter | false; maxMessageLength?: number; @@ -247,7 +249,8 @@ export function createDeweeHandler(options: DeweeHandlerOptions): Handler { const limiter: RateLimiter | null = o.rateLimit === false ? null - : o.rateLimit ?? memoryRateLimiter({ chat: [20, 60_000], session: [10, 600_000], verified: [30, 600_000], link: [5, 600_000] }); + : o.rateLimit ?? + memoryRateLimiter({ chat: [20, 60_000], session: [10, 600_000], preauth: [120, 60_000], verified: [30, 600_000], link: [5, 600_000] }); const staticAgents: AgentInfo[] = o.agents === "*" ? [] : (o.agents ?? []).map((a) => (typeof a === "string" ? { id: a } : a)); const wildcardAgents = o.agents === "*"; @@ -408,6 +411,8 @@ export function createDeweeHandler(options: DeweeHandlerOptions): Handler { */ async function rebindRefusal(req: Request, claims: VisitorClaims): Promise { if (!idn?.rebindEachRequest || !claims.ids) return null; + const throttled = await preauthRefusal(req); + if (throttled) return throttled; let user: AuthenticatedUser | null; try { user = await idn.authenticate(req); @@ -415,10 +420,17 @@ export function createDeweeHandler(options: DeweeHandlerOptions): Handler { console.warn("[dewee-webchat] identity.authenticate failed:", e); return identityFail(req, "SOURCE_UNAVAILABLE", { action: "retry_later", audience: "end_user" }); } - const same = !!user && typeof user.subject === "string" && !!claims.sub && (await subjectDigest(secret!, user.subject)) === claims.sub; + const same = + !!user && typeof user.subject === "string" && !!claims.sub && constantTimeEqual(await subjectDigest(secret!, user.subject), claims.sub); return same ? null : identityFail(req, "IDENTITY_REQUIRED"); } + /** A generous per-IP cap in front of every `authenticate` call, so a client cannot drive the host's login check at will. */ + async function preauthRefusal(req: Request): Promise { + if (!limiter || (await limiter(clientIp(req), "preauth"))) return null; + return fail(req, 429, "Too many requests, slow down", "rate_limited"); + } + const actorOf = (claims: VisitorClaims): GatewayActor => (claims.ids ? { identitySession: claims.ids } : o.userIdPrefix + claims.v); function identitySigner(): IdentitySigner { @@ -572,6 +584,8 @@ export function createDeweeHandler(options: DeweeHandlerOptions): Handler { const body: any = await req.json().catch(() => ({})); // Fail closed: a signed-in user gets an identity session or no session at all. A login that could // not be read is not "signed out", so it never falls back to anonymous. + const throttled = await preauthRefusal(req); + if (throttled) return throttled; let user: AuthenticatedUser | null; try { user = await idn!.authenticate(req); diff --git a/packages/dewee-webchat/src/server/security.ts b/packages/dewee-webchat/src/server/security.ts index 8282342..eb58063 100644 --- a/packages/dewee-webchat/src/server/security.ts +++ b/packages/dewee-webchat/src/server/security.ts @@ -44,8 +44,14 @@ export interface VisitorClaims { /** Longest identity session token the gateway issues (matches the `@dewee/identity` limit). */ const MAX_IDENTITY_SESSION_LENGTH = 512; -/** Longest sealed form: 12-byte IV + 512 bytes + 16-byte tag, base64url. */ -const MAX_SEALED_LENGTH = Math.ceil(((12 + MAX_IDENTITY_SESSION_LENGTH + 16) * 4) / 3); +/** + * Longest sealed plaintext, `{"t":"","h":""}` in UTF-8: the 15 bytes of JSON syntax, + * the session at worst 6 bytes per character (a `\u00XX` escape; a non-ASCII character is at most 3 + * UTF-8 bytes per UTF-16 unit), and the 43-character base64url HMAC-SHA-256 digest. + */ +const MAX_SEALED_PLAINTEXT = '{"t":"","h":""}'.length + 6 * MAX_IDENTITY_SESSION_LENGTH + 43; +/** Longest sealed form: 12-byte IV + plaintext + 16-byte tag, base64url. */ +const MAX_SEALED_LENGTH = Math.ceil(((12 + MAX_SEALED_PLAINTEXT + 16) * 4) / 3); const AGENT_CLAIM_RE = /^[A-Za-z0-9_.:-]{1,64}$/; const sealKeyCache = new Map>(); @@ -104,6 +110,15 @@ export async function subjectDigest(secret: string, subject: string): Promise { let wire: Omit = claims; if (claims.ids !== undefined && claims.ida !== undefined) { @@ -154,18 +169,25 @@ export function randomId(bytes = 16): string { /** * Rate-limit buckets: `session` (new visitor sessions, per IP), `chat` (messages, per visitor and IP), - * and with signed-in users `verified` (signed-in `/session` calls, per user) and `link` (link starts, - * per user). + * and with signed-in users `preauth` (calls to the host's `authenticate`, per IP), `verified` + * (signed-in `/session` calls, per user) and `link` (link starts, per user). */ -export type RateLimitBucket = "session" | "chat" | "verified" | "link"; +export type RateLimitBucket = "session" | "chat" | "preauth" | "verified" | "link"; + +/** Default `preauth` limit: generous, it only caps how hard a client can drive `authenticate`. */ +const DEFAULT_PREAUTH_LIMIT: [number, number] = [120, 60_000]; /** Returns true when the key may proceed. Implementations may be async (KV, DO, Redis…). */ export type RateLimiter = (key: string, bucket: RateLimitBucket) => boolean | Promise; -/** `[max, windowMs]` per bucket. `verified` and `link` fall back to the `session` limit when not set. */ +/** + * `[max, windowMs]` per bucket. `preauth` defaults to 120 per minute; `verified` and `link` fall back to + * the `session` limit when not set. + */ export interface MemoryRateLimits { chat: [number, number]; session: [number, number]; + preauth?: [number, number]; verified?: [number, number]; link?: [number, number]; } @@ -177,7 +199,7 @@ export interface MemoryRateLimits { export function memoryRateLimiter(limits: MemoryRateLimits): RateLimiter { const windows = new Map(); return (key, bucket) => { - const [max, windowMs] = limits[bucket] ?? limits.session; + const [max, windowMs] = limits[bucket] ?? (bucket === "preauth" ? DEFAULT_PREAUTH_LIMIT : limits.session); const id = `${bucket}:${key}`; const now = Date.now(); const w = windows.get(id); diff --git a/packages/dewee-webchat/test/identity.test.ts b/packages/dewee-webchat/test/identity.test.ts index b5019eb..b31ed46 100644 --- a/packages/dewee-webchat/test/identity.test.ts +++ b/packages/dewee-webchat/test/identity.test.ts @@ -17,6 +17,7 @@ import { type RateLimitBucket, } from "../src/server"; import { deweeIdentitySdk } from "../src/server/identity"; +import { constantTimeEqual } from "../src/server/security"; import { ApiClient } from "../src/widget/api-client"; import { pickStrings } from "../src/widget/i18n"; @@ -282,6 +283,24 @@ describe("identity sessions: sealed token", () => { }); }); +describe("identity sessions: sealed token limits", () => { + it("seals and opens a session token of the SDK's 512-character maximum, with a subject digest", async () => { + for (const ids of ["a".repeat(512), '"\\'.repeat(256), "\u0001".repeat(512), "é".repeat(512)]) { + const token = await signVisitorToken(VISITOR_SECRET, { v: "visitor_max512", exp: Date.now() + 60_000, ids, ida: "support", sub: "d".repeat(43) }); + expect(await verifyVisitorToken(VISITOR_SECRET, token)).toEqual({ v: "visitor_max512", exp: expect.any(Number), ids, ida: "support", sub: "d".repeat(43) }); + } + const tooLong = await signVisitorToken(VISITOR_SECRET, { v: "visitor_max512", exp: Date.now() + 60_000, ids: "a".repeat(513), ida: "support" }); + expect(await verifyVisitorToken(VISITOR_SECRET, tooLong)).toBeNull(); + }); + + it("compares strings in constant time without changing the answer", () => { + expect(constantTimeEqual("abc", "abc")).toBe(true); + expect(constantTimeEqual("abc", "abd")).toBe(false); + expect(constantTimeEqual("abc", "abcd")).toBe(false); + expect(constantTimeEqual("", "")).toBe(true); + }); +}); + describe("identity sessions: fail closed", () => { it("refuses identity mode combined with client credentials and other misconfiguration", () => { const base = { gatewayUrl: "https://gw.example.com", token: GW_TOKEN, secret: VISITOR_SECRET, agents: ["support"] }; @@ -455,17 +474,31 @@ describe("identity sessions: reuse and rate limits", () => { const calls: Array<[string, RateLimitBucket]> = []; const h = makeHandler({}, { rateLimit: (key, bucket) => (calls.push([key, bucket]), true) }); const a = (await session(h, "user-a")).json(); - expect(calls).toEqual([["subject:user-a", "verified"]]); + expect(calls).toEqual([["unknown", "preauth"], ["subject:user-a", "verified"]]); calls.length = 0; expect((await link(h, a.token)).status).toBe(200); expect(calls).toEqual([[`visitor:${a.visitorId}`, "link"]]); calls.length = 0; const anon = (await session(h)).json(); - expect(calls.map(([, b]) => b)).toEqual(["session"]); + expect(calls.map(([, b]) => b)).toEqual(["preauth", "session"]); calls.length = 0; const again = (await session(h, undefined, { token: anon.token })).json(); expect(again).toEqual({ visitorId: anon.visitorId, token: anon.token, expiresAt: anon.expiresAt }); - expect(calls).toEqual([]); + expect(calls).toEqual([["unknown", "preauth"]]); + }); + + it("caps authenticate calls per IP before running it, on /session and on rebinding", async () => { + let authCalls = 0; + const counted = async (req: Request) => (authCalls++, cookieUser(req)); + const h = makeHandler({ authenticate: counted, rebindEachRequest: true }, { rateLimit: undefined }); + const a = (await session(h, "user-a")).json(); + for (let i = 0; i < 118; i++) await session(h, undefined, { token: "x" }); + expect((await chat(h, a.token, {}, "uid=user-a")).status).toBe(200); // the 120th call + const before = authCalls; + const r = await chat(h, a.token, {}, "uid=user-a"); + expect([r.status, r.json().error.code]).toEqual([429, "rate_limited"]); + expect((await session(h, "user-a")).status).toBe(429); + expect(authCalls).toBe(before); // refused before authenticate ran }); it("answers 429 once a user's signed-in session budget is spent", async () => {