From d91a408c6413c8cce9f9fb4727b83cc8299e13d5 Mon Sep 17 00:00:00 2001 From: Taea Vogel Date: Mon, 2 Dec 2024 16:51:03 -0500 Subject: [PATCH] Merge remote-tracking branch 'origin/develop' into update-orbitdb --- .github/secrets/Certificates.p12.gpg | Bin 3303 -> 3334 bytes .github/secrets/decrypt_secrets.sh | 3 +- ...ppStore_comquietmobile.mobileprovision.gpg | Bin 7879 -> 7906 bytes .github/workflows/e2e-ios.yml | 2 +- .github/workflows/integration-tests.yml | 2 +- .github/workflows/mobile-deploy-ios-test.yml | 72 +++ .github/workflows/mobile-deploy-ios.yml | 6 +- .gitignore | 2 +- 3rd-party/auth | 2 +- CHANGELOG.md | 13 +- package-lock.json | 603 +++++++++++++----- package.json | 5 +- packages/backend/package-lock.json | 139 +++- packages/backend/package.json | 7 +- .../nest/auth/services/chainServiceBase.ts | 14 + .../auth/services/crypto/crypto.service.ts | 152 +++++ .../src/nest/auth/services/crypto/types.ts | 27 + .../services/invites/invite.service.spec.ts | 97 +++ .../auth/services/invites/invite.service.ts | 98 +++ .../services/members/device.service.spec.ts | 36 ++ .../auth/services/members/device.service.ts | 47 ++ .../src/nest/auth/services/members/types.ts | 14 + .../services/members/user.service.spec.ts | 46 ++ .../auth/services/members/user.service.ts | 86 +++ .../services/roles/channel.service.spec.ts | 89 +++ .../auth/services/roles/channel.service.ts | 103 +++ .../nest/auth/services/roles/permissions.ts | 3 + .../nest/auth/services/roles/role.service.ts | 109 ++++ .../src/nest/auth/services/roles/roles.ts | 31 + .../src/nest/auth/sigchain.service.module.ts | 10 + .../src/nest/auth/sigchain.service.spec.ts | 93 +++ .../backend/src/nest/auth/sigchain.service.ts | 158 +++++ .../backend/src/nest/auth/sigchain.spec.ts | 36 ++ packages/backend/src/nest/auth/sigchain.ts | 137 ++++ packages/backend/src/nest/auth/types.ts | 13 + packages/backend/src/nest/common/utils.ts | 2 +- .../connections-manager.module.ts | 2 + .../connections-manager.service.spec.ts | 27 +- .../connections-manager.service.tor.spec.ts | 14 +- .../connections-manager.service.ts | 100 ++- .../src/nest/local-db/local-db.service.ts | 38 ++ .../src/nest/local-db/local-db.types.ts | 4 + packages/backend/tsconfig.build.json | 4 +- packages/backend/tsconfig.json | 4 +- packages/common/src/index.ts | 3 +- packages/common/src/invitationCode.test.ts | 172 ----- packages/common/src/invitationCode.ts | 298 --------- .../invitationLink/invitationLink.const.ts | 20 + .../src/invitationLink/invitationLink.test.ts | 354 ++++++++++ .../src/invitationLink/invitationLink.ts | 273 ++++++++ .../invitationLink.validator.ts | 500 +++++++++++++++ packages/common/src/tests.ts | 35 +- packages/desktop/CHANGELOG.md | 12 + packages/desktop/package-lock.json | 4 +- packages/desktop/package.json | 2 +- .../MathMessage/MathMessageComponent.test.tsx | 6 +- .../Tabs/Invite/Invite.component.test.tsx | 2 +- .../widgets/channels/BasicMessage.test.tsx | 14 +- .../widgets/channels/ChannelMessages.test.tsx | 2 +- .../channels/NestedMessageContent.test.tsx | 6 +- .../widgets/channels/TextMessage.tsx | 2 +- .../invitation/customProtocol.saga.test.ts | 96 +-- .../sagas/invitation/customProtocol.saga.ts | 4 +- packages/desktop/src/renderer/theme.ts | 2 +- .../src/tests/invitationLink.test.ts | 2 +- .../src/tests/joiningWithQSS.test.ts | 284 ++++----- packages/mobile/CHANGELOG.md | 12 + packages/mobile/android/app/build.gradle | 4 +- packages/mobile/ios/Quiet/Info.plist | 4 +- packages/mobile/ios/QuietTests/Info.plist | 4 +- packages/mobile/package-lock.json | 4 +- packages/mobile/package.json | 2 +- packages/state-manager/src/index.ts | 2 +- .../createNetwork/createNetwork.saga.test.ts | 91 +-- .../invitationCode/invitationCode.test.ts | 62 +- .../invitationCode/invitationCode.ts | 16 +- packages/types/src/network.ts | 47 +- tsconfig.build.json | 3 +- 78 files changed, 3766 insertions(+), 1028 deletions(-) create mode 100644 .github/workflows/mobile-deploy-ios-test.yml create mode 100644 packages/backend/src/nest/auth/services/chainServiceBase.ts create mode 100644 packages/backend/src/nest/auth/services/crypto/crypto.service.ts create mode 100644 packages/backend/src/nest/auth/services/crypto/types.ts create mode 100644 packages/backend/src/nest/auth/services/invites/invite.service.spec.ts create mode 100644 packages/backend/src/nest/auth/services/invites/invite.service.ts create mode 100644 packages/backend/src/nest/auth/services/members/device.service.spec.ts create mode 100644 packages/backend/src/nest/auth/services/members/device.service.ts create mode 100644 packages/backend/src/nest/auth/services/members/types.ts create mode 100644 packages/backend/src/nest/auth/services/members/user.service.spec.ts create mode 100644 packages/backend/src/nest/auth/services/members/user.service.ts create mode 100644 packages/backend/src/nest/auth/services/roles/channel.service.spec.ts create mode 100644 packages/backend/src/nest/auth/services/roles/channel.service.ts create mode 100644 packages/backend/src/nest/auth/services/roles/permissions.ts create mode 100644 packages/backend/src/nest/auth/services/roles/role.service.ts create mode 100644 packages/backend/src/nest/auth/services/roles/roles.ts create mode 100644 packages/backend/src/nest/auth/sigchain.service.module.ts create mode 100644 packages/backend/src/nest/auth/sigchain.service.spec.ts create mode 100644 packages/backend/src/nest/auth/sigchain.service.ts create mode 100644 packages/backend/src/nest/auth/sigchain.spec.ts create mode 100644 packages/backend/src/nest/auth/sigchain.ts create mode 100644 packages/backend/src/nest/auth/types.ts delete mode 100644 packages/common/src/invitationCode.test.ts delete mode 100644 packages/common/src/invitationCode.ts create mode 100644 packages/common/src/invitationLink/invitationLink.const.ts create mode 100644 packages/common/src/invitationLink/invitationLink.test.ts create mode 100644 packages/common/src/invitationLink/invitationLink.ts create mode 100644 packages/common/src/invitationLink/invitationLink.validator.ts diff --git a/.github/secrets/Certificates.p12.gpg b/.github/secrets/Certificates.p12.gpg index bd1a14ef78ff822d16364248c346758eb6ad6835..717f69172fc78944f6f531ab0e8b84096a867989 100644 GIT binary patch literal 3334 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 QUCbf8S@6ho&hl_b{jCCTO8@`> literal 3303 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 diff --git a/.github/secrets/decrypt_secrets.sh b/.github/secrets/decrypt_secrets.sh index 19dfa83dc7..e493c9591d 100755 --- a/.github/secrets/decrypt_secrets.sh +++ b/.github/secrets/decrypt_secrets.sh @@ -7,8 +7,7 @@ gpg --quiet --batch --yes --decrypt --passphrase="$IOS_CERTIFICATE_KEY" --output mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles -cp ./.github/secrets/match_AppStore_comquietmobile.mobileprovision ~/Library/MobileDevice/Provisioning\ Profiles/718ac015-309f-49b6-9653-f6cf84a6377c.mobileprovision - +cp ./.github/secrets/match_AppStore_comquietmobile.mobileprovision ~/Library/MobileDevice/Provisioning\ Profiles/762df280-302c-4336-a56d-c74914169337.mobileprovision security create-keychain -p "" build.keychain security import ./.github/secrets/Certificates.p12 -t agg -k ~/Library/Keychains/build.keychain -P "$IOS_CERTIFICATE_KEY" -A diff --git a/.github/secrets/match_AppStore_comquietmobile.mobileprovision.gpg b/.github/secrets/match_AppStore_comquietmobile.mobileprovision.gpg index 4f35bf8199ab2b0badb8c54aa39eecb1cdb2f622..8740531928349938baa25249c7f301e90850d18b 100644 GIT binary patch literal 7906 zcmV<89v$I~4Fm}T2y(Ud_UXhiO!U&~0ko0fsX7?{%9sHGp1KI_zex*@#>`{)YPohp#7U@Xx)j|&~8^m^Va^%Jer%_UH~4+dB)$k}7J9#3)1X{_Xemh)g`@om!v0fB_Fu?&Vz5p}Z2fm= z6R%_5HGGzSHm-Y{8c!?JJ{tMAq9QtTt`0b_e1)b!zPrkOl#h5ax4D-J=cJtH z8G@EEdFKSc$B+*+=|W`V3j!lj2b@(@Kv>=g%lW^&P8B%pfKUo1$yYJ4M!<2)$@t@a zeSyX>n!Er(vJfC$p~_bwK%$&A$l{P2FBShPZ=P(GYqODl+tF8XCa@^=j|_3FMkZE6 z#j`D=f9-U2AMmMDNn0zke4Z9DXto#sHHlLPGEBN!t^eiIWu`BxsKiye1(M}aP-2U0 z;`mPh<6(nD?@xh6ss<@u^vI|XD>XjKK`g$4<(_3SCt17)GQ!fDkjA+O1cUx0VWvREJdwH1`n&W5Pkv z{V9mV8)N&SmIA8`upthLmr>&${3IkaZGN8ksa^eHGksCUzW((ATvlRGUQeD68{~3Z zRWEphja1HBxESl5CT?ulHh+pNY^K(g)xSF9^cZCkwEZse!()}JFZasCTQWK~{7rHU zG_?EN5W|rjlCI6%#XZIfz{`Im{r!D4o0mAf(&%?8VuID4&FOX7X?7YMOx8fk2#ZzZ zhecZKHnRF0rcX+8N#R08G^WJ31?fkP(4XhJA`V$uw69nC=bI(eKa+l7#>sBXs)Bf$ zOGd*Lu1iR)q##FI;lU-igI}f^XguG*jxTSYZ?1vltjjLjcaV2!TOgU&RfCPV<%fdE zmrW}pcc6#Mw}`1NT9KdURaKSPwF+{bPHqshiDrfeYjP!ODH}m|WEUnKQyf3`lY~pU zB#?iFsmSSI7cI=xD{Zp=!(}R+<;UK+&3)c>Rb#y*F*C)nMO%JRi=t@mRj{x*Ddt)} z=&{2&^}M#s>X%j7Y_(4AjkoK}fDf)VC>y3|9014n7x}=UhBdV71Jo4;DN3vdB^bg{ ztAR0yIt5h{sn$sRq&^ysKGQ+eM8Hoeq-st6I;{4HeB3Ov%prP%0->=_7oi*WfuQ_RJ@X{33jCGR=RTe0Ma9*Hfu|BSeI*(M+bjZiL}3u^ z;IeH9*(L#bJbIV(NRA8;ekUYC+#G^L>`&Zc5Q0fg%NnM$s!!`V5KqeDLn07E_YEO8 zCkS26QFJ&x5Qr$f9F=k+C4rotDk-~MDZVQ;5WmG$AvOGcJjn0!2(xTP+ogheynIy# z+{fb}2>6=C6+TRP_mvOB`+f2zFtKKwVmG6u_A0R(3==6%N;UK?yd+Shln7e{zQ$ow zIkkgexA={<0wfGOYo{0t_SM(piuyE8bgGkS2+{wJ);`kOgcY3J*V_2jHXN&?`pnGA z-#)G7HtuTf$zlFGvYEi1b{lAqc}^ymxZ1;AoLesN`p94Qo56O6&2_X?d${RE9<8lp zy#KQVc?9x~*XYr_Fss63$pd-*gw`32kRBQK;+ z1MN){<_)#!mP{Zk&6ddPydvrHyJk-s!t>%yZh2^y4vTSDDceD7k1t<}luaLteE=#I zI{^=6*ePfMp0iG~eQ(D#lGv2Yj2NJ|Yg!X3Mf?cyCzm6mJ)a;R=>!(#iWAjJ0HvU{ zo>5XfZTERgy5X7^A|uuzRv$Tm-0QvdV~`4txI@nqNf8x$Cv zI2Q_{a(B~J;sWaLOdrD$M<_^9zD+?=vQM=#kJ{)^Z>Q)1%?Lzeg*t;dBl#zG2>eQ0 zzQY(|s;i|mIbXZ*wxgixgN&gCwGvOwdn<+2FJ2T;qZdbsUI`d2C5Gi|UPoQe%$Ggy z8<{Vsa`;n7oB)oN7Y;8YO3a7d7nLVFgwcb1QoQdT)D_E@E`NHdB9eYzJUbZ9NS#`J zDf=p8-HG4$OP@f0kjDvDaaNVTy!Y%&7Kvotv~RK8w|G1oayZB8v>!E>bnNG(F+vke z8t9hejp3dlF94oFs#|1LG`q6dcDIvb=Ol9|!&JeU8M(b(kk4P|`2B9^>sr93Bv1!_ zgU0yZkw_24O~U|vXr^xZ(}qz~>sunvD$)lg@5o}NTX8cZ0agjyOvPyY6L^VH4Bfj5 z`17#K42v;EG_&h#PlZgoyodbq%b~(}Lw{UPu$AdSVL}ii9ADmGenUcp4P7g#(0g95 zVu-}qXUv`j__IEEVYfFe@d_on?-k5?BCm7;yt!gMq{dG;axqaYPzBwuqp93~3hrSh ztDJ?R)hJc3XxR-5T)YGfIQ~-%1E*`Y{P8jbKt0hC?0czc34848ZVF(u6=z zN3@I&V!ZCaW~kdefc*tnN*y36P3_az-}h0t(9r>Dn_8Llip}fUGRJEQ3na#;+l|Xrl?noB8Y)v7M7= zM)6YWtHO2haO~=A3rFBA?$i}}TIt>@cdp>PJg7E#mr}>Im4G2zjOko@=)Yc2<3wMWi5AslKDou{$o?VG+mLk0|23tni&yMYrC z`UqRw$0*tH5_i6?xq~F|iOQ(#jAI2+;Kql`3yJtk#v;KlKAnBC!9LUuK+)9{g)ye( zic|V^tU5MXy^@)~1?>-@lLX~c_p+@E(=vp=y$i+6``S{OHQS_Fvj$hfdDS|C2k%-0C1^J+(qj;@3HFB z0Bbm4iEZLGc=nu%IlPZieU~)etN&xF67N{?F|DgvxySQyx#IaFd23Lmix15!RV&cD zdkTO2=hy}rzk1f!b^5{7ox9W*1H_ZhS$CW7U)A^h{y)lik$8jr_rp~JhBl6Hp$W&y zRIm&@00-G<3t`=^uo=n=>ss`Nst)59j9J69fk@2{A8z1Yld%aAc!lj%=&GtnT6BYr zh(0itpc{r=&&o@or-B9pi3I4dJHE1N>zJGkU18wq^v7o)2$L?~(47hi_h>$^zIH{~ zZ42z9Zc7`O5%;%WH13PYc?r zyRC4*5T48E-sP@dc_USOs>WOo8$WQWs1p_T+nX5kq`qkXkQaUW1-GdhWt;1BC&Ph11NxS|{!@F{@tfQ-p^1F<$MWqT_YkyHGVrTi z=R+kaW;vTBWUG;pvNlH`qNGGzu_e=?ocFg@ip3J#x*=f3?ggR3;hV4Umr` zC^?^dv-c5tMD*!@X`4>?b4tsUmbXDF48$xKG3hPW?5MOyx>JVRCLNqZk-*Y7nEs0f znmHowHk)D_mIZ`9LRFp6x1cHX7G3fi^Ver9BLV?w3RH`BovNGLMHl$5$$*uT? z%UU+7&%*9hS6^J1T#A#M9uStudQ_j z08@w?VP0+l1gNK`;dksXp3vaq7P5FU_+tPemXJj@jB^r1jYqD?yn)71QoGbqQjfw& zE4V|ql`&V9_xEccVBUe#{D}0S15YHb%p;+Qa+y-)*l%c;B%GXko!-X3vz{_%q^akZ50EW zXg>cR+K{gR24{yMmg&1_6T+*!D~2{MB-cCFr&^HU=t40IQ{DTb52Z0pRiA6>rS#fd z2+`Ux+iQ&Q+)X5g1eNFO9}PCbDt6o?MaHi9LetT($uL=H!-t1y8vXtE`3+FuMsxwqEM^Vg@yJ1z_CN!LEp>e{uP2oTh z_qw|gXd%p*#Wa245duaei-aY)bP$Sy6LR?g}F7@nd%eWn(60~d8vg3gO zxdBOgF!W8*Lln)(ou@?Oy+d1Yd& zuIR=%LUH0=GC?5(->L?QJNU9f-Jr4tTkB2_`;`E44vo{HTHt3P(?37u-I=qPQ@q!%qh zQ#8_=6v9I`o*I(z$}H>?)iqUH2f$69m*HutZ4?=LreDNjTV&}^wO=2tJixz3(RSR#~6+TC?(ad?oz827&sU^|aCG}AJg5;+eXO>48{Eyx>RN*CJVK-_X zL%2gfJu^Gej^ANGE4Cfohe;vnybh*=iXPKn;l$e-z z$RH>wjHeo>@}c@`l5phC5HZd#m$MZd|dB{?wpTfs{Qacmxv&-1X1% zba$hyy8YTQOrI1KDd~%3)=^5frHIJ*ufS8!7e5~W%q+TWEbumz(TBrReN>LPm4Sv) z?-RpXJG?vHD5e&YCH+FUd;}tc;4B@P{hE@|zU_W(GVS^b&>>|N!nj|U_cR1wrwKu@ z1Gr4P6ex*ncnm8k>+Cga;5S^w^zd-x;r#)_NwWms)`X`r0rRn=eDs|{$g3JrUbr&M)_ z4>lo}0!YLR!yua#5pIXe9IaM33?WRJ9YrQc1l)^B*zOR?>^;qC*;l&W>N^Vytvikl zLxnpEtfA&#-f56$PUU_uOAZOohX|xC_7xM?AAt{Jry$7dP_ysOZ!1K}q5g$}aAvjX zPIe1E*W{HRyC<0-Lf@9u0VPjdyXsglY_SY*tMLj?StRkx8H*;&JwA<%^uhT)qkG)Q z-afE&>HRiA$iPx$esR^*2+2vU`@@gA&&9t?sSUFt#W-qY-=vcL$?Yr^>F`+3dtd`+}r^xByHgv~#?5-A9l&gXPS;F%opuyy_KZ@0Z9A`NI z$UUeewG1pNo2lrgY^Y3DI8z`GlFuGAT+ug60aylBju$V`vYiW>N5o}q zZ0LNG_^E@6kz+-riTFD&`f?NtbY!KUqvgJx{goTH*rythf+7_uN&AA!rxj zpzi#}q1wA&hs=HaJG96?v*eWYBA0N)Qi7f0n<1sq9lf+>#v9wA$Dx#knxLECH+yy} zICc>_Mx1b>e@XwR^U)KJuw;PVWuGmt4llk|dkRlK9Wx-wX|U7n4XcO!iX7b3+` zBqCFp1I)_w%AL}&U8nbg{lqc_yI(Cjy!)^@U;p69#N+@4G=8xhuW6DeBbgW3M(elQ z+vUISa$}S_gWc4R6Ds}2X&ny zV$0be2?n#GAuj4`r?2Nu#K}*k`0J0$bsR2k!D%6=3bg9#Vp$xP3VR*5bcLS^Yd-pN zFO3|@ZYSn1GeYec_3Zg(qhT}RRsT)p8!aG5tNQh9!fI-m5-$MC3&!*?rn1%#vy-ZHSz8PUJl?jBS2*8QNfND5-ee~y`GfazA0i}ebG z=@%{W)&aCCiGDf7d+@Y`?RbGo!#CYGgPBtPNRm^)fENjuD4^)bq(I#lZtpA&{JeRE z_R@2ZD%8tJt9sO^!0TI)*Di2Ft5{^qn@m{`tBX75+ao2S{I z!?xVooB`VdkEi% z@WMe>K%c-U0om?N@j9K+4%Ul3aFm9kVj0Y4SwZeZexYH3$uC{Vo>P?`7pjV+iynaXZr>|zx628gFbx(T09FS)kjhEGuUepcZb=1{TwT`OT0B5j` z#2FtPRD?g|%3Bv04h&QQ69ug9!w|mPflD5J_@>MCJf&Os$1=CT2kcyu+3!UP*OmudjxD>KmXv{-D1?u>WfBe5z#-nVDv7x8|)t&kT6h!#$VHix+1%}#OPTiBvGm^VgX-nup=SYs%} zX8)Qrpm8h^KNLxt-M6ncTM(3ZB5J)zGH{1NH&XPJN$3kPf3jh#5Zz41fcFm6L|wSd zlJe#?Wire*TAUuGbv87lHk|AgcFkes17Q|wwt$xEIL%1}DI0bid*t}- zD0ixZVOMDyH!>XT4dV)7Q>k0rtZKG;RQUsWRaPu#m-;a@Zi%Y8R zst7%o7YiLT>%e!@ML(Cw9i^MvIl!c)Q`n`aKyNGMt4D7qYGoFh<%DDp6#kb@=Dc=S zr_sOY2_DWpn4~x2)CRR5y&1;o*@yWA{FZ2Be@WnJ%pM1N9&+J0DkXjCI|goESgt2Y zct0B#;ebHzX}#+eFz&Gb^vy{vJx-_q4|a%yRc&K<00)7!A|R8GAd5kxjfs)p%Ztp2(uam7c*f8?y_KlR2&1qk%$Jk=W_bc zbxr`snJcyY0-ikko zc}cN3M$`$V1-K?PVI)PV4{fh>C`N}=^9<+P4P6lR@NhK1uTL(u M@&WVm6w%3?Kew@c+{40mr##(%4?YsI#09R%+lZal0CGn*_ZL;MxOU zB2^bYfXvNc)&-qGFYzHdscImu{8@jfG1#Ts1Joc95x?#6hgzhwxxLOoX$|!BafDtk zL)MS-tD%@gn*H*?PiNh+0K7T(m)4&Gh`4b6^~TW0?t9Y_oPR5t@848$Duc8;I|$QE zbhLPX%6XT{oJy3VD_UVRckH!+?y|z|tPR>k;gon2fS*Ea8JKpe-Q#t2B)HFqXhx!X z{Y$EH2L2yakpsJ7KC2bfF}gNk)UoRUh3?zE&=GLU^kCmjEE`|jK&2jXxW zo*sxl7LleoL9|>*__`Ia0eLFwe978?T>Gq-8%0It5dW>R=HDTWr<=+;AOczff2^6u z;6WQGSnn;Sv58VS=s&=w?;}J;88H+OtP(zZ22d=amh@ z>rp_%kCXN=y&K`MV%V^M)U<8o;b>%!IRLpq3GmaVa%l1VrJtlGS?#MMaK?$b zsYtkkKy-?{J8l5{jYdG3->8&*sgub)P@r55Fjef)x)`Rt5??jwG&Yh(+DKfNBekEP zzz|ij6mlqEg2NP~Q=*HC1`~r2AvsvnJH+!>kvppF3|>;&fCvKG=nvy&0#dp*^ua8dKX4zD=7dBS+goF zS81KB>Dy;`PpBeo8It(HZFYPnghK-)18qu}mN-kxBCRQ*zkGZo4ezd03EJoij`}0l zYJe&P08d-b2PQ>XyyFlgctGrb3oQFIKF<#>k1`ng)MlE&DG3~pWxcVy(;eMqA~-EU zs~~O||aX*zV3`ETKdO8_=ho}C=FY;epk>@ zJ@weDfIUiQ>SMW&p#=ds2wgQ$857IrJk36H6QrsF=^KJr@;DeXMtr4o-KQfF+CAX# zoaekM3WrC1QzQhvvdtg32{*LF$Y`r|#~}ZfKi)L0fw-JlU5 zlHgtuO}RUJz?entKkS{jB)^8s-FQvfS;9;IKwAO|X{@iR=Q%grPfxV9>^F zvw~nkIFoWeX9rWFHO@E0fE7*Lo#A^+xSTh){2WX$vS@q)#Z+i>^tn1B?4HgB&bAMS zGB1TV=gTvx;SjlqUEwp+sz0j_3?`_>+y)W=Sef9gQMosR+mu_&ajgOY988*zHg=ckaAQxT5pB z#)Y;CF={8#;C8^7Py^m~Q}@}AlPEx#zNUf}Spx!aUgTouCIFDU_%X&iu^K}v3H(D< z7yh?F8nqhfMfa}~8&J!1GRd0{N+9r|k7SY@_iF|9=Y(vCuW(~Adhy9`&4vqBSm#xA zW6E#3F3;w5Gr|3xsI7s1yt1HA7;Bp?r*Y%X+Lsf3^#jp3K4Xblt{vn-{a8V|q)KY) zrDDjIbOC`~_h?%)jsyr3*xP`!d8Y^bq=}#s9@q&o-#w&G%?+9?Pf!Pr)~z^yOig`S zmgqwO9q$VWz?(&*8yYR;I9v=1qal(ZN@v0U!1eFB$R^Qk!0K`>R5Ut52Pke;7GAEH z_E7Kk4CZBVY-x$vJ8WJnzRHx1I54#BQ!yMCpD~@+oFA{}GpbVqY`UsD=O(xQ(|gQS zpLnYPr;G=_*~<~mbX^Y!K?|Slctlt-2Sg)nHjHVW-?q!ym7WHhR0|!AIrH#Y_(DAP z3DyiEA+mjN5Z!TGfNec(k}17bGKDnOpY6h^Y`B`hJ`I}`!$po{oHEfeab21*A?6x< zonFcLYvsjC*Q8Rp>+Cb%i+hF(K9EKo#JzWcIs zIL8^0Knv@RdNy2**9-;$5q71wM2-JVJ}C!TWEV4`gA=oS4)dn86rnI^SUh#cHGT3@ zq~w;RhhWc3g$+%HMMA-s4a9>=x_zhH3b$b3gu|Oi;j#Nlg0T{%n@v{P&SP+Fh}FNLThK`g52dwB-FW`m9uN@t>c zfI}n0ko7$3T%x}VeKA8bm1YmJ$^zr*F=vY}S*_~q>Nn7JW~!OoHTI=CCyi{LhfdE{ zpmfh2N)^H6&oXvlc|4#7ba_(1B7@9T4>yDbL#SSmG0|~gZ}|EMHj zZ{%5larnducT4n3-AV8bCNkiq}@Kx?^&ReneB% z;Q}kqy9@5J+wUtPAFwXbUqQp{_ml8IZ-@^m4cE9sl`?)R>!klJigD4pKaGuE9E*DL zy2v(aPtKbNUX>)iB+!JOj+(UE1Wxp;>d=Nc*@eA{9YlyA zZ!P~A(}_FdcXM|V5$Fv<;HO+dtLO)p`tpt5UR;s~`|aV<0Trt92tTqhWj0)~Bbp%$ zkypveq!0t4_Uga-wOm#j^vaQ5F}Pj0qrvSusf&~sWJX?SBC1fx<)%FZE^cdb`8kj3LFyCYs8PNa^LMBfWcQVQtiCP1v1riEE=cXbl2(}mr_OW5?q^P zgqyLhdiH~%?v-n%(No38BRoXJTbJum(lY6Z<&?MS!i8jBg%vZu5ioHP*TNHP8hZyN zGesz~YpD))p2vzpe}|v~iekI-1B-d;EK7Ib6D(?l-(~o_MzlcF3#QRS2Cv%$j9__# zYEU-G`v{M)sCQza!}5bS!76xZZSkVp7gR z2#6^eG=m0$y_r^pU%i$fkTr=%nAnYp1xG7Nmcx8Eow}Zw6&o%MwVG)oBdr%P z8F{=yv+g53B$m;gGCi3^?C(L1r~z0{k7vB@tg?yg#mb`&n-8;&m)mt+Zd(%CGOZr# ze8CpE%lPmFTK*mM#2n{1WL@+F-tDfh#${H-?trI4|DP_M2dvd;@JHW z&R5Y+(9n!bU0dVMt8eMyqsurgT$zsUe@JOyK+5)nC3fEcEYu)1<{1v1IBNy_M?fKG zl=dr;^IQ=ZJSBSol29B2JR=7$#H8n?OdyHZQn%CS64p@ zD25yIn{mKn3V4(a$SXxhh&h;QxgG$TY81PE7`zq>*vD%a9n8> z8Fe1kOrq~bA|D*GplZdiCX;ZizG~{(iexQTu=<$W*Pu(dc<{J;X@rsjj947V+LjOF z+SG$kryBNAeAwYz4F=x1fevc`*ja=2K1daiIP1N4muxHEb1X5t(^lZb7g@29)T5Ik zw=VdHN>rk?9UGm9Rnqcsj9(-c2_arT>>lN4%B?*Y_g8yK+wFT)20np7=@^{iF;gcG zhsCw%Sf*|#n4%+ClKRj8jsbTV?wn7gDPV>!>=i4y}44< z&^jL>We-^XKv_ zL-a9bYEB!3^7M1>Yb}GZ1 zyE*^GBh*Qp0IdFi+Oc6#NQTOSg{^7C@>yNBGz5`c4mRWjV`7-Zzl7 z5|Pq~Gh;)>Z*p}*E}%d95ZuTHK&j$TNQ#^rfz2WOm%lo`1#g#=axTG@piV zhO0DIP=hg7M45fNz%J&~+I7EFs2yNM8#qP}hn?Gjb+P;tCH$YrFa$KCwEi)6ZL6BS z_U5LLE4`Ff$&zU_0Yd-%qO!ZRzn%EdTql4XAay&@@Tn?;;K5w4+uTqm^$E&`O&{I{7`H zC2i{SHv%XQCVEMa>>)=mXf-bwpoJSR_Rm%_G~@?DUhEe#{GmQ{IHHk_G;yKlJ9H}z zUkz^x@c$P8>U-?XRPMU7Uf--(UDzm-0d37vSiF6u@jBsj0j^8&A;p5dOS1B^D`S-_ zr{v#M)wWZ9= zFNQPtr=ByiFs3btH)-5NmvinA#LDmw^^`?y3o!SC4gMkdoR|0K-kj#=13lZ)YDrU` z464C~Uv2?~|5`8%3EkPqJwY20uZOgQ3yA*UIL8;mh*-^k73m#>qtAJgGid0ECfFQ$ z%UhBR1SUy>3O3YXu6JD<_ab^b_G>XN3NW$&Hd-GmK>kH(C)rMP?qrN?sRNsyV zp(w8F<7pKvUfB?R6~!RWJ5_u@_n<4;Wj;fS-*v9-b(QtfkY3O?r%q0Qz(azTN_b}K zX9S>631*ORwO8>Aj5dUwUvOsv(0vMin&lm+ZGnOn?RY!FB=Yj8(vNxu%qhk=HzJ8S>Vk23y+_sH{h zEr$Rhn;em`rOtQn0N#gsXi4nR^whyb%p&W&iO6SfKJImm5k4&ThQi5O_QpIqB|*R8 zD4bx zg;QKZ7qXMZ1EU!g`*57DLUh(H`VyTfRzwk`UKlAzbl0fgQ=(NBxL4Y=aJoNL;o=x7 zs`(~9iRawdHj6z^wrEMC3493RQ5E-Sy)M$#zpHO2HZ;s7s-kOPiARB$&wXJApiAS4%lkk8-EE}X)Q(`$zB%i*soQHP(%hn%T zqjeR$+9G6b5X6$U1#7(F&xARC0B{t7qQNan?9mw#MMkU>4pMbtkBXK>ZZdYl%UfuD z(;{eZL93Ei$GE3WjH`cM<6X=-@wKkmMxI{pm-K8|*LN;ny{VI4b?&^2kY5 zn(12ow;b3mtJGSJAo-ZfrhK|ePXI>P&(F*s33VJuJPuY4F9Hajdg4l5s`O$>o%Cd) zXPUYMp~jQiLElWvRPXt>DhTi54|!(z(@G5!pM_|)55UVbVL814b5klgX-+~Y6rl80 zb6D5Re)bq)a6Cs}(Z=#=K6fVJrJ~Izv(s%8`f*9j`LjICuzJ0Mx{sQl7?+3bXAZjw z-L|@Hs;Zi2lsc&2zP1NL+<;#S7)Ug zawGRAEcGJ8iV{*=|kz;eE4Yz+pvV z-ZNV@=s7-TqX-$*RhXHH=-Fu3rOCvOzkamnGfN&rh0VfquiyB}D-$nomW=E=13r3E|%0PSPEo@=;x^s1s*oJGphm7on!7%Fb&XeE2!JB_2qr= z@r6Dyjb@A5;w+(lbt4U?CtUbJP0wbibUGNGCQUI(3ZzIXMU?V|@Lo7>i04K@HA5gv z3eqP7bI}_zd8Q#uTsplCQ|OoJ0Ig(_VJXFO*+4}=U^+rRYmYb3_n2d^skq9Y^20Lg{NiO%(9Lf??+kNraPDxnq6>x-q(E)@-w;Y+}>N z=LQ+$13A=WEYtq$>q0y9qA}|OL!<$c(1{v4rq{+QSLyi3 z)YXYsPhUCtY0C)lQ`k-Ep~3>@uV*p8&U86qS=q3g5BZ-01Rp`RApQGp46UrdC0PPr zi)TD{4WO#Q4X}kE)54en*88e>7vVJK;dWrXryP0s`wIny7oi~`5q|2_fCTVCdKM}} zbdgOq!+YHnp$na^g0Z_^s{!B_=to!)-AG>N#jRZU1@V@5YIb zB$@UIIw;6hJlufv19LM4Z@CFSXvpp@QF!$D)*UQzcQ%g;I8LYe4b%bgUJ zFCG@&E#A6Pj-^F|vHtp%nTw$B3i(~`TMgF@XsrAsKcwDI?g-VzS5?Q&+?Lp!ahnsD z4V&$3xz_q-OsZS327DN!3+Z4thIIa0rwGi@1Q;Iu*T5xiM23-G;{6M_m;`G~W4ycC zp8A5+lWe9o!HpWnHP}$y)q@~3v1jaCLJ`mGL+6e1GJ0V(= lp~baJW5g3T`1_!m1(A*U;*V42L0|A5X`+7$)EA3*W}KUtEL8vi diff --git a/.github/workflows/e2e-ios.yml b/.github/workflows/e2e-ios.yml index 01544c3e4f..33180207ef 100644 --- a/.github/workflows/e2e-ios.yml +++ b/.github/workflows/e2e-ios.yml @@ -5,7 +5,7 @@ on: workflow_dispatch jobs: detox-ios: timeout-minutes: 25 - runs-on: [macos-latest-xlarge] + runs-on: [macos-13-xlarge] steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index a1147078df..f5eaf22e8c 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -14,7 +14,7 @@ jobs: strategy: matrix: - os: [ubuntu-20.04] #, macos-latest, windows-2019] + os: [ubuntu-20.04] #, macos-13, windows-2019] steps: - name: 'Print OS' diff --git a/.github/workflows/mobile-deploy-ios-test.yml b/.github/workflows/mobile-deploy-ios-test.yml new file mode 100644 index 0000000000..c6d1f1499d --- /dev/null +++ b/.github/workflows/mobile-deploy-ios-test.yml @@ -0,0 +1,72 @@ +name: Test IOS build + +on: + pull_request: + +jobs: + test-build-ios: + # needs: detox-ios + runs-on: ${{ matrix.os }} + + strategy: + matrix: + os: [macos-13] + + steps: + - name: "Print OS" + run: echo ${{ matrix.os }} + + - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + with: + submodules: 'recursive' + lfs: true + + - name: Install gpg + run: brew install gnupg + + - name: Setup XCode + uses: maxim-lobanov/setup-xcode@9a697e2b393340c3cacd97468baa318e4c883d98 # v1.5.1 + with: + xcode-version: '15.2' + + - name: Setup environment + uses: ./.github/actions/setup-env + with: + cachePrefix: "deploy-ios" + bootstrap-packages: "@quiet/eslint-config,@quiet/logger,@quiet/common,@quiet/types,@quiet/state-manager,@quiet/backend,@quiet/identity,@quiet/mobile,backend-bundle" + + - name: Install pod dependencies + run: | + cd ./packages/mobile/ios + pod install + shell: bash + + - name: Setup provisioning profile + run: ./.github/secrets/decrypt_secrets.sh + env: + IOS_PROFILE_KEY: ${{ secrets.IOS_PROFILE_KEY }} + IOS_CERTIFICATE_KEY: ${{ secrets.IOS_CERTIFICATE_KEY }} + + - name: Build + run: | + cd ./packages/mobile/ios + xcodebuild archive \ + -workspace Quiet.xcworkspace \ + -scheme Quiet \ + -configuration Release \ + -archivePath build/Quiet.xcarchive \ + PROVISIONING_PROFILE="762df280-302c-4336-a56d-c74914169337" \ + CODE_SIGN_IDENTITY="Apple Distribution: A Quiet LLC (CTYKSWN9T4)" + + - name: Export .ipa + run: | + cd ./packages/mobile/ios + xcodebuild \ + -exportArchive \ + -archivePath build/Quiet.xcarchive \ + -exportOptionsPlist ci.plist \ + -exportPath build/ + + - name: Cleanup environment + if: always() + run: security delete-keychain build.keychain && rm -f ~/Library/MobileDevice/Provisioning\ Profiles/*.mobileprovision diff --git a/.github/workflows/mobile-deploy-ios.yml b/.github/workflows/mobile-deploy-ios.yml index 6ec3cdabd6..18f073265a 100644 --- a/.github/workflows/mobile-deploy-ios.yml +++ b/.github/workflows/mobile-deploy-ios.yml @@ -14,7 +14,7 @@ jobs: strategy: matrix: - os: [macos-latest] + os: [macos-13] steps: - name: "Print OS" @@ -59,8 +59,8 @@ jobs: -scheme Quiet \ -configuration Release \ -archivePath build/Quiet.xcarchive \ - PROVISIONING_PROFILE="718ac015-309f-49b6-9653-f6cf84a6377c" \ - CODE_SIGN_IDENTITY="Apple Distribution: Zbay LLC (CTYKSWN9T4)" + PROVISIONING_PROFILE="762df280-302c-4336-a56d-c74914169337" \ + CODE_SIGN_IDENTITY="Apple Distribution: A Quiet LLC (CTYKSWN9T4)" - name: Export .ipa run: | diff --git a/.gitignore b/.gitignore index 26f6769174..59d0b2a34b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,5 @@ node_modules -lerna-debug.log +*.log c4/.structurizr c4/workspace.json .ignore diff --git a/3rd-party/auth b/3rd-party/auth index 4a78dca870..fd7101145f 160000 --- a/3rd-party/auth +++ b/3rd-party/auth @@ -1 +1 @@ -Subproject commit 4a78dca870be429c1a43107ad254bd89214c040d +Subproject commit fd7101145fc15aeb14bda46578b7a4d6d84e4e5b diff --git a/CHANGELOG.md b/CHANGELOG.md index eb09e98277..898b478ae3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,9 +2,18 @@ ## [unreleased] - ### Chores +### New features + +* Adds basic sigchain functions ([#2625](https://github.com/TryQuiet/quiet/issues/2625)) +* Instantiates signature chain when creating communities and reloading application ([#2626](https://github.com/TryQuiet/quiet/issues/2626)) +* Added in LFA-ready invite links ([#2627](https://github.com/TryQuiet/quiet/issues/2627)) + +## [2.3.2] + +### Chores -* Moved some responsibilities of identity management to the backend ([#2617](https://github.com/TryQuiet/quiet/pull/2617)) +* Moved some responsibilities of identity management to the backend ([#2602](https://github.com/TryQuiet/quiet/issues/2602)) +* Added auth submodule in preparation for future encyrption work ([#2623](https://github.com/TryQuiet/quiet/issues/2623)) ### Fixes diff --git a/package-lock.json b/package-lock.json index af442550de..428c281381 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3713,9 +3713,9 @@ } }, "node_modules/es-abstract": { - "version": "1.23.3", - "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.23.3.tgz", - "integrity": "sha512-e+HfNH61Bj1X9/jLc5v1owaLYuHdeHHSQlkhCBiTK8rBvKaULl/beGMxwrMXjpYrv4pz22BlY570vVePA2ho4A==", + "version": "1.23.5", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.23.5.tgz", + "integrity": "sha512-vlmniQ0WNPwXqA0BnmwV3Ng7HxiGlh6r5U6JcTMNx8OilcAGqVJBHJcPjqOMaczU9fRuRK5Px2BdVyPRnKMMVQ==", "dependencies": { "array-buffer-byte-length": "^1.0.1", "arraybuffer.prototype.slice": "^1.0.3", @@ -3732,7 +3732,7 @@ "function.prototype.name": "^1.1.6", "get-intrinsic": "^1.2.4", "get-symbol-description": "^1.0.2", - "globalthis": "^1.0.3", + "globalthis": "^1.0.4", "gopd": "^1.0.1", "has-property-descriptors": "^1.0.2", "has-proto": "^1.0.3", @@ -3748,10 +3748,10 @@ "is-string": "^1.0.7", "is-typed-array": "^1.1.13", "is-weakref": "^1.0.2", - "object-inspect": "^1.13.1", + "object-inspect": "^1.13.3", "object-keys": "^1.1.1", "object.assign": "^4.1.5", - "regexp.prototype.flags": "^1.5.2", + "regexp.prototype.flags": "^1.5.3", "safe-array-concat": "^1.1.2", "safe-regex-test": "^1.0.3", "string.prototype.trim": "^1.2.9", @@ -3823,13 +3823,13 @@ } }, "node_modules/es-to-primitive": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.2.1.tgz", - "integrity": "sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz", + "integrity": "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==", "dependencies": { - "is-callable": "^1.1.4", - "is-date-object": "^1.0.1", - "is-symbol": "^1.0.2" + "is-callable": "^1.2.7", + "is-date-object": "^1.0.5", + "is-symbol": "^1.0.4" }, "engines": { "node": ">= 0.4" @@ -4880,9 +4880,9 @@ } }, "node_modules/flatted": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.1.tgz", - "integrity": "sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==" + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.2.tgz", + "integrity": "sha512-AiwGJM8YcNOaobumgtng+6NHuOqC3A7MixFeDafM3X9cIUM+xUXoS5Vfgf+OihAYe20fxqNM9yPBXJzRtZ/4eA==" }, "node_modules/follow-redirects": { "version": "1.15.9", @@ -5415,11 +5415,14 @@ } }, "node_modules/gopd": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz", - "integrity": "sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.1.0.tgz", + "integrity": "sha512-FQoVQnqcdk4hVM4JN1eromaun4iuS34oStkdlLENLdpULsuQcTyXj8w7ayhuUfPwEYZ1ZOooOTT6fdA9Vmx/RA==", "dependencies": { - "get-intrinsic": "^1.1.3" + "get-intrinsic": "^1.2.4" + }, + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -5494,9 +5497,12 @@ } }, "node_modules/has-proto": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.0.3.tgz", - "integrity": "sha512-SJ1amZAJUiZS+PhsVLf5tGydlaVB8EdFpaSO4gmiUKUOxk8qzn5AIy4ZeJUmh22znIdk/uMAUT2pl3FxzVUH+Q==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.1.0.tgz", + "integrity": "sha512-QLdzI9IIO1Jg7f9GT1gXpPpXArAn6cS31R1eEZqz08Gc+uQ8/XiqHWt17Fiw+2p6oTTIq5GXEpQkAlA88YRl/Q==", + "dependencies": { + "call-bind": "^1.0.7" + }, "engines": { "node": ">= 0.4" }, @@ -5505,9 +5511,9 @@ } }, "node_modules/has-symbols": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz", - "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", "engines": { "node": ">= 0.4" }, @@ -5926,24 +5932,41 @@ "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", "dev": true }, + "node_modules/is-async-function": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.0.0.tgz", + "integrity": "sha512-Y1JXKrfykRJGdlDwdKlLpLyMIiWqWvuSd17TvZk68PLAOGOoF4Xyav1z0Xhoi+gCYjZVeC5SI+hYFOfvXmGRCA==", + "dependencies": { + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-bigint": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.0.4.tgz", - "integrity": "sha512-zB9CruMamjym81i2JZ3UMn54PKGsQzsJeo6xvN3HJJ4CAsQNB6iRutp2To77OfCNuoxspsIhzaPoO1zyCEhFOg==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", + "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", "dependencies": { - "has-bigints": "^1.0.1" + "has-bigints": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, "node_modules/is-boolean-object": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.1.2.tgz", - "integrity": "sha512-gDYaKHJmnj4aWxyj6YHyXVpdQawtVLHU5cb+eztPGczf6cjuTdwve5ZIEfgXqH4e57An1D1AKf8CZ3kYrQRqYA==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.0.tgz", + "integrity": "sha512-kR5g0+dXf/+kXnqI+lu0URKYPKgICtHGGNCDSB10AaUFj3o/HkB3u7WfpRBJGFopxxY0oH3ux7ZsDjLtK7xqvw==", "dependencies": { - "call-bind": "^1.0.2", - "has-tostringtag": "^1.0.0" + "call-bind": "^1.0.7", + "has-tostringtag": "^1.0.2" }, "engines": { "node": ">= 0.4" @@ -6055,6 +6078,20 @@ "node": ">=0.10.0" } }, + "node_modules/is-finalizationregistry": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.0.tgz", + "integrity": "sha512-qfMdqbAQEwBw78ZyReKnlA8ezmPdb9BemzIIip/JkjaZUhitfXDkkr+3QTboW0JrSXT1QWyYShpvnNHGZ4c4yA==", + "dependencies": { + "call-bind": "^1.0.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", @@ -6064,6 +6101,20 @@ "node": ">=8" } }, + "node_modules/is-generator-function": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.0.10.tgz", + "integrity": "sha512-jsEjy9l3yiXEQ+PsXdmBwEPcOxaXWLspKdplFUVI9vq1iZgIekeC0L167qeu86czQaxed3q/Uzuw0swL0irL8A==", + "dependencies": { + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-glob": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", @@ -6090,6 +6141,17 @@ "integrity": "sha512-z7CMFGNrENq5iFB9Bqo64Xk6Y9sg+epq1myIcdHaGnbMTYOxvzsEtdYqQUylB7LxfkvgrrjP32T6Ywciio9UIQ==", "dev": true }, + "node_modules/is-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-negative-zero": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", @@ -6110,11 +6172,12 @@ } }, "node_modules/is-number-object": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.0.7.tgz", - "integrity": "sha512-k1U0IRzLMo7ZlYIfzRu23Oh6MiIFasgpb9X76eqfFZAqwH44UI4KTBvBYIZ1dSL9ZzChTB9ShHfLkR4pdW5krQ==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.0.tgz", + "integrity": "sha512-KVSZV0Dunv9DTPkhXwcZ3Q+tUc9TsaE1ZwX5J2WMvsSGS6Md8TFPun5uwh0yRdrNerI6vf/tbJxqSx4c1ZI1Lw==", "dependencies": { - "has-tostringtag": "^1.0.0" + "call-bind": "^1.0.7", + "has-tostringtag": "^1.0.2" }, "engines": { "node": ">= 0.4" @@ -6168,12 +6231,14 @@ } }, "node_modules/is-regex": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.1.4.tgz", - "integrity": "sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.0.tgz", + "integrity": "sha512-B6ohK4ZmoftlUe+uvenXSbPJFo6U37BH7oO1B3nQH8f/7h27N56s85MhUtbFJAziz5dcmuR3i8ovUl35zp8pFA==", "dependencies": { - "call-bind": "^1.0.2", - "has-tostringtag": "^1.0.0" + "call-bind": "^1.0.7", + "gopd": "^1.1.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" }, "engines": { "node": ">= 0.4" @@ -6182,6 +6247,17 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-set": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-shared-array-buffer": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.3.tgz", @@ -6215,11 +6291,12 @@ } }, "node_modules/is-string": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.0.7.tgz", - "integrity": "sha512-tE2UXzivje6ofPW7l23cjDOMa09gb7xlAqG6jG5ej6uPV32TlWP3NKPigtaGeHNu9fohccRYvIiZMfOOnOYUtg==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.0.tgz", + "integrity": "sha512-PlfzajuF9vSo5wErv3MJAKD/nqf9ngAs1NFQYm16nUYFO2IzxJ2hcm+IOCg+EEopdykNNUhVq5cz35cAUxU8+g==", "dependencies": { - "has-tostringtag": "^1.0.0" + "call-bind": "^1.0.7", + "has-tostringtag": "^1.0.2" }, "engines": { "node": ">= 0.4" @@ -6229,11 +6306,13 @@ } }, "node_modules/is-symbol": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.4.tgz", - "integrity": "sha512-C/CPBqKWnvdcxqIARxyOh4v1UUEOCHpgDa0WYgpKDFMszcrPcffg5uhwSgPCLD2WWxmq6isisz87tzT01tuGhg==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.0.tgz", + "integrity": "sha512-qS8KkNNXUZ/I+nX6QT8ZS1/Yx0A444yhzdTKxCzKkNjQ9sHErBxJnJAgh+f5YhusYECEcjo4XcyH87hn6+ks0A==", "dependencies": { - "has-symbols": "^1.0.2" + "call-bind": "^1.0.7", + "has-symbols": "^1.0.3", + "safe-regex-test": "^1.0.3" }, "engines": { "node": ">= 0.4" @@ -6280,6 +6359,17 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-weakmap": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-weakref": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.0.2.tgz", @@ -6291,6 +6381,21 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-weakset": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.3.tgz", + "integrity": "sha512-LvIm3/KWzS9oRFHugab7d+M/GcBXuXX5xZkzPmN+NxihdQlZUQ4dWuSV1xR/sq6upL1TJEDrfBgRepHFdBtSNQ==", + "dependencies": { + "call-bind": "^1.0.7", + "get-intrinsic": "^1.2.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-wsl": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", @@ -8455,9 +8560,9 @@ } }, "node_modules/object-inspect": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.2.tgz", - "integrity": "sha512-IRZSRuzJiynemAXPYtPe5BoI/RESNYR7TYm50MC5Mqbd3Jmw5y790sErYw3V6SryFJD64b74qQQs9wn5Bg/k3g==", + "version": "1.13.3", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.3.tgz", + "integrity": "sha512-kDCGIbxkDSXE3euJZZXzc6to7fCrKHNI/hSRQnRuQ+BWjFNzZwiFF8fj/6o2t2G9/jTj8PSIYTfCLelLZEeRpA==", "engines": { "node": ">= 0.4" }, @@ -9201,9 +9306,9 @@ } }, "node_modules/prettier": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.3.3.tgz", - "integrity": "sha512-i2tDNA0O5IrMO757lfrdQZCc2jPNDVntV0m/+4whiDfWaTKfMNgR7Qz0NAeGz/nRqF4m5/6CLzbP4/liHt12Ew==", + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.4.1.tgz", + "integrity": "sha512-G+YdqtITVZmOJje6QkXQWzl3fSfMxFwm1tjTyo9exhkmWSqC4Yhd1+lug++IlR2mvRVAxEDDWYkQdeSztajqgg==", "bin": { "prettier": "bin/prettier.cjs" }, @@ -9719,6 +9824,26 @@ "node": ">=8" } }, + "node_modules/reflect.getprototypeof": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.7.tgz", + "integrity": "sha512-bMvFGIUKlc/eSfXNX+aZ+EL95/EgZzuwA0OBPTbZZDEJw/0AkentjMuM1oiRfwHrshqk4RzdgiTg5CcDalXN5g==", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "which-builtin-type": "^1.1.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/regexp.prototype.flags": { "version": "1.5.3", "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.3.tgz", @@ -10754,9 +10879,9 @@ } }, "node_modules/ts-api-utils": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.3.0.tgz", - "integrity": "sha512-UQMIo7pb8WRomKR1/+MFVLTroIvDVtMX3K6OUir8ynLyzB8Jeriont2bTAtmNPa1ekAgN7YPDyf6V+ygrdU+eQ==", + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.4.3.tgz", + "integrity": "sha512-i3eMG77UTMD0hZhgRS562pv83RC6ukSAC2GMNWc+9dieh/+jDM5u5YG+NHX6VNDRHQcHwmsTHctP9LhbC3WxVw==", "engines": { "node": ">=16" }, @@ -10962,16 +11087,17 @@ } }, "node_modules/typed-array-byte-offset": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.2.tgz", - "integrity": "sha512-Ous0vodHa56FviZucS2E63zkgtgrACj7omjwd/8lTEMEPFFyjfixMZ1ZXenpgCFBBt4EC1J2XsyVS2gkG0eTFA==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.3.tgz", + "integrity": "sha512-GsvTyUHTriq6o/bHcTd0vM7OQ9JEdlvluu9YISaA7+KzDzPaIzEeDFNkTfhdE3MYcNhNi0vq/LlegYgIs5yPAw==", "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.7", "for-each": "^0.3.3", "gopd": "^1.0.1", "has-proto": "^1.0.3", - "is-typed-array": "^1.1.13" + "is-typed-array": "^1.1.13", + "reflect.getprototypeof": "^1.0.6" }, "engines": { "node": ">= 0.4" @@ -10981,16 +11107,16 @@ } }, "node_modules/typed-array-length": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.6.tgz", - "integrity": "sha512-/OxDN6OtAk5KBpGb28T+HZc2M+ADtvRxXrKKbUwtsLgdoxgX13hyy7ek6bFRl5+aBs2yZzB0c4CnQfAtVypW/g==", + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.7.tgz", + "integrity": "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==", "dependencies": { "call-bind": "^1.0.7", "for-each": "^0.3.3", "gopd": "^1.0.1", - "has-proto": "^1.0.3", "is-typed-array": "^1.1.13", - "possible-typed-array-names": "^1.0.0" + "possible-typed-array-names": "^1.0.0", + "reflect.getprototypeof": "^1.0.6" }, "engines": { "node": ">= 0.4" @@ -11203,24 +11329,75 @@ } }, "node_modules/which-boxed-primitive": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.0.tgz", + "integrity": "sha512-Ei7Miu/AXe2JJ4iNF5j/UphAgRoma4trE6PtisM09bPygb3egMH3YLW/befsWb1A1AxvNSFidOFTB18XtnIIng==", + "dependencies": { + "is-bigint": "^1.1.0", + "is-boolean-object": "^1.2.0", + "is-number-object": "^1.1.0", + "is-string": "^1.1.0", + "is-symbol": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-builtin-type": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.0.tgz", + "integrity": "sha512-I+qLGQ/vucCby4tf5HsLmGueEla4ZhwTBSqaooS+Y0BuxN4Cp+okmGuV+8mXZ84KDI9BA+oklo+RzKg0ONdSUA==", + "dependencies": { + "call-bind": "^1.0.7", + "function.prototype.name": "^1.1.6", + "has-tostringtag": "^1.0.2", + "is-async-function": "^2.0.0", + "is-date-object": "^1.0.5", + "is-finalizationregistry": "^1.1.0", + "is-generator-function": "^1.0.10", + "is-regex": "^1.1.4", + "is-weakref": "^1.0.2", + "isarray": "^2.0.5", + "which-boxed-primitive": "^1.0.2", + "which-collection": "^1.0.2", + "which-typed-array": "^1.1.15" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-builtin-type/node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==" + }, + "node_modules/which-collection": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.0.2.tgz", - "integrity": "sha512-bwZdv0AKLpplFY2KZRX6TvyuN7ojjr7lwkg6ml0roIy9YeuSr7JS372qlNW18UQYzgYK9ziGcerWqZOmEn9VNg==", + "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", + "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", "dependencies": { - "is-bigint": "^1.0.1", - "is-boolean-object": "^1.1.0", - "is-number-object": "^1.0.4", - "is-string": "^1.0.5", - "is-symbol": "^1.0.3" + "is-map": "^2.0.3", + "is-set": "^2.0.3", + "is-weakmap": "^2.0.2", + "is-weakset": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, "node_modules/which-typed-array": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.15.tgz", - "integrity": "sha512-oV0jmFtUky6CXfkqehVvBP/LSWJ2sy4vWMioiENyJLePrBO/yKyV9OyJySfAKosh+RYkIl5zJCNZ8/4JncrpdA==", + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.16.tgz", + "integrity": "sha512-g+N+GAWiRj66DngFwHvISJd+ITsyphZvD1vChfVg6cEdnzy53GzB3oy0fUNlvhz7H7+MiqhYr26qxQShCpKTTQ==", "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.7", @@ -14187,9 +14364,9 @@ } }, "es-abstract": { - "version": "1.23.3", - "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.23.3.tgz", - "integrity": "sha512-e+HfNH61Bj1X9/jLc5v1owaLYuHdeHHSQlkhCBiTK8rBvKaULl/beGMxwrMXjpYrv4pz22BlY570vVePA2ho4A==", + "version": "1.23.5", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.23.5.tgz", + "integrity": "sha512-vlmniQ0WNPwXqA0BnmwV3Ng7HxiGlh6r5U6JcTMNx8OilcAGqVJBHJcPjqOMaczU9fRuRK5Px2BdVyPRnKMMVQ==", "requires": { "array-buffer-byte-length": "^1.0.1", "arraybuffer.prototype.slice": "^1.0.3", @@ -14206,7 +14383,7 @@ "function.prototype.name": "^1.1.6", "get-intrinsic": "^1.2.4", "get-symbol-description": "^1.0.2", - "globalthis": "^1.0.3", + "globalthis": "^1.0.4", "gopd": "^1.0.1", "has-property-descriptors": "^1.0.2", "has-proto": "^1.0.3", @@ -14222,10 +14399,10 @@ "is-string": "^1.0.7", "is-typed-array": "^1.1.13", "is-weakref": "^1.0.2", - "object-inspect": "^1.13.1", + "object-inspect": "^1.13.3", "object-keys": "^1.1.1", "object.assign": "^4.1.5", - "regexp.prototype.flags": "^1.5.2", + "regexp.prototype.flags": "^1.5.3", "safe-array-concat": "^1.1.2", "safe-regex-test": "^1.0.3", "string.prototype.trim": "^1.2.9", @@ -14279,13 +14456,13 @@ } }, "es-to-primitive": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.2.1.tgz", - "integrity": "sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz", + "integrity": "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==", "requires": { - "is-callable": "^1.1.4", - "is-date-object": "^1.0.1", - "is-symbol": "^1.0.2" + "is-callable": "^1.2.7", + "is-date-object": "^1.0.5", + "is-symbol": "^1.0.4" } }, "escalade": { @@ -15000,9 +15177,9 @@ } }, "flatted": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.1.tgz", - "integrity": "sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==" + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.2.tgz", + "integrity": "sha512-AiwGJM8YcNOaobumgtng+6NHuOqC3A7MixFeDafM3X9cIUM+xUXoS5Vfgf+OihAYe20fxqNM9yPBXJzRtZ/4eA==" }, "follow-redirects": { "version": "1.15.9", @@ -15394,11 +15571,11 @@ } }, "gopd": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz", - "integrity": "sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.1.0.tgz", + "integrity": "sha512-FQoVQnqcdk4hVM4JN1eromaun4iuS34oStkdlLENLdpULsuQcTyXj8w7ayhuUfPwEYZ1ZOooOTT6fdA9Vmx/RA==", "requires": { - "get-intrinsic": "^1.1.3" + "get-intrinsic": "^1.2.4" } }, "graceful-fs": { @@ -15450,14 +15627,17 @@ } }, "has-proto": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.0.3.tgz", - "integrity": "sha512-SJ1amZAJUiZS+PhsVLf5tGydlaVB8EdFpaSO4gmiUKUOxk8qzn5AIy4ZeJUmh22znIdk/uMAUT2pl3FxzVUH+Q==" + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.1.0.tgz", + "integrity": "sha512-QLdzI9IIO1Jg7f9GT1gXpPpXArAn6cS31R1eEZqz08Gc+uQ8/XiqHWt17Fiw+2p6oTTIq5GXEpQkAlA88YRl/Q==", + "requires": { + "call-bind": "^1.0.7" + } }, "has-symbols": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz", - "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==" + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==" }, "has-tostringtag": { "version": "1.0.2", @@ -15764,21 +15944,29 @@ "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", "dev": true }, + "is-async-function": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.0.0.tgz", + "integrity": "sha512-Y1JXKrfykRJGdlDwdKlLpLyMIiWqWvuSd17TvZk68PLAOGOoF4Xyav1z0Xhoi+gCYjZVeC5SI+hYFOfvXmGRCA==", + "requires": { + "has-tostringtag": "^1.0.0" + } + }, "is-bigint": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.0.4.tgz", - "integrity": "sha512-zB9CruMamjym81i2JZ3UMn54PKGsQzsJeo6xvN3HJJ4CAsQNB6iRutp2To77OfCNuoxspsIhzaPoO1zyCEhFOg==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", + "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", "requires": { - "has-bigints": "^1.0.1" + "has-bigints": "^1.0.2" } }, "is-boolean-object": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.1.2.tgz", - "integrity": "sha512-gDYaKHJmnj4aWxyj6YHyXVpdQawtVLHU5cb+eztPGczf6cjuTdwve5ZIEfgXqH4e57An1D1AKf8CZ3kYrQRqYA==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.0.tgz", + "integrity": "sha512-kR5g0+dXf/+kXnqI+lu0URKYPKgICtHGGNCDSB10AaUFj3o/HkB3u7WfpRBJGFopxxY0oH3ux7ZsDjLtK7xqvw==", "requires": { - "call-bind": "^1.0.2", - "has-tostringtag": "^1.0.0" + "call-bind": "^1.0.7", + "has-tostringtag": "^1.0.2" } }, "is-builtin-module": { @@ -15839,12 +16027,28 @@ "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==" }, + "is-finalizationregistry": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.0.tgz", + "integrity": "sha512-qfMdqbAQEwBw78ZyReKnlA8ezmPdb9BemzIIip/JkjaZUhitfXDkkr+3QTboW0JrSXT1QWyYShpvnNHGZ4c4yA==", + "requires": { + "call-bind": "^1.0.7" + } + }, "is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", "dev": true }, + "is-generator-function": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.0.10.tgz", + "integrity": "sha512-jsEjy9l3yiXEQ+PsXdmBwEPcOxaXWLspKdplFUVI9vq1iZgIekeC0L167qeu86czQaxed3q/Uzuw0swL0irL8A==", + "requires": { + "has-tostringtag": "^1.0.0" + } + }, "is-glob": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", @@ -15865,6 +16069,11 @@ "integrity": "sha512-z7CMFGNrENq5iFB9Bqo64Xk6Y9sg+epq1myIcdHaGnbMTYOxvzsEtdYqQUylB7LxfkvgrrjP32T6Ywciio9UIQ==", "dev": true }, + "is-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==" + }, "is-negative-zero": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", @@ -15876,11 +16085,12 @@ "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==" }, "is-number-object": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.0.7.tgz", - "integrity": "sha512-k1U0IRzLMo7ZlYIfzRu23Oh6MiIFasgpb9X76eqfFZAqwH44UI4KTBvBYIZ1dSL9ZzChTB9ShHfLkR4pdW5krQ==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.0.tgz", + "integrity": "sha512-KVSZV0Dunv9DTPkhXwcZ3Q+tUc9TsaE1ZwX5J2WMvsSGS6Md8TFPun5uwh0yRdrNerI6vf/tbJxqSx4c1ZI1Lw==", "requires": { - "has-tostringtag": "^1.0.0" + "call-bind": "^1.0.7", + "has-tostringtag": "^1.0.2" } }, "is-obj": { @@ -15913,14 +16123,21 @@ "dev": true }, "is-regex": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.1.4.tgz", - "integrity": "sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.0.tgz", + "integrity": "sha512-B6ohK4ZmoftlUe+uvenXSbPJFo6U37BH7oO1B3nQH8f/7h27N56s85MhUtbFJAziz5dcmuR3i8ovUl35zp8pFA==", "requires": { - "call-bind": "^1.0.2", - "has-tostringtag": "^1.0.0" + "call-bind": "^1.0.7", + "gopd": "^1.1.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" } }, + "is-set": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==" + }, "is-shared-array-buffer": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.3.tgz", @@ -15945,19 +16162,22 @@ "dev": true }, "is-string": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.0.7.tgz", - "integrity": "sha512-tE2UXzivje6ofPW7l23cjDOMa09gb7xlAqG6jG5ej6uPV32TlWP3NKPigtaGeHNu9fohccRYvIiZMfOOnOYUtg==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.0.tgz", + "integrity": "sha512-PlfzajuF9vSo5wErv3MJAKD/nqf9ngAs1NFQYm16nUYFO2IzxJ2hcm+IOCg+EEopdykNNUhVq5cz35cAUxU8+g==", "requires": { - "has-tostringtag": "^1.0.0" + "call-bind": "^1.0.7", + "has-tostringtag": "^1.0.2" } }, "is-symbol": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.4.tgz", - "integrity": "sha512-C/CPBqKWnvdcxqIARxyOh4v1UUEOCHpgDa0WYgpKDFMszcrPcffg5uhwSgPCLD2WWxmq6isisz87tzT01tuGhg==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.0.tgz", + "integrity": "sha512-qS8KkNNXUZ/I+nX6QT8ZS1/Yx0A444yhzdTKxCzKkNjQ9sHErBxJnJAgh+f5YhusYECEcjo4XcyH87hn6+ks0A==", "requires": { - "has-symbols": "^1.0.2" + "call-bind": "^1.0.7", + "has-symbols": "^1.0.3", + "safe-regex-test": "^1.0.3" } }, "is-text-path": { @@ -15983,6 +16203,11 @@ "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", "dev": true }, + "is-weakmap": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==" + }, "is-weakref": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.0.2.tgz", @@ -15991,6 +16216,15 @@ "call-bind": "^1.0.2" } }, + "is-weakset": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.3.tgz", + "integrity": "sha512-LvIm3/KWzS9oRFHugab7d+M/GcBXuXX5xZkzPmN+NxihdQlZUQ4dWuSV1xR/sq6upL1TJEDrfBgRepHFdBtSNQ==", + "requires": { + "call-bind": "^1.0.7", + "get-intrinsic": "^1.2.4" + } + }, "is-wsl": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", @@ -17679,9 +17913,9 @@ } }, "object-inspect": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.2.tgz", - "integrity": "sha512-IRZSRuzJiynemAXPYtPe5BoI/RESNYR7TYm50MC5Mqbd3Jmw5y790sErYw3V6SryFJD64b74qQQs9wn5Bg/k3g==" + "version": "1.13.3", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.3.tgz", + "integrity": "sha512-kDCGIbxkDSXE3euJZZXzc6to7fCrKHNI/hSRQnRuQ+BWjFNzZwiFF8fj/6o2t2G9/jTj8PSIYTfCLelLZEeRpA==" }, "object-keys": { "version": "1.1.1", @@ -18211,9 +18445,9 @@ "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==" }, "prettier": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.3.3.tgz", - "integrity": "sha512-i2tDNA0O5IrMO757lfrdQZCc2jPNDVntV0m/+4whiDfWaTKfMNgR7Qz0NAeGz/nRqF4m5/6CLzbP4/liHt12Ew==" + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.4.1.tgz", + "integrity": "sha512-G+YdqtITVZmOJje6QkXQWzl3fSfMxFwm1tjTyo9exhkmWSqC4Yhd1+lug++IlR2mvRVAxEDDWYkQdeSztajqgg==" }, "prettier-linter-helpers": { "version": "1.0.0", @@ -18602,6 +18836,20 @@ "strip-indent": "^3.0.0" } }, + "reflect.getprototypeof": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.7.tgz", + "integrity": "sha512-bMvFGIUKlc/eSfXNX+aZ+EL95/EgZzuwA0OBPTbZZDEJw/0AkentjMuM1oiRfwHrshqk4RzdgiTg5CcDalXN5g==", + "requires": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "which-builtin-type": "^1.1.4" + } + }, "regexp.prototype.flags": { "version": "1.5.3", "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.3.tgz", @@ -19351,9 +19599,9 @@ "dev": true }, "ts-api-utils": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.3.0.tgz", - "integrity": "sha512-UQMIo7pb8WRomKR1/+MFVLTroIvDVtMX3K6OUir8ynLyzB8Jeriont2bTAtmNPa1ekAgN7YPDyf6V+ygrdU+eQ==", + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.4.3.tgz", + "integrity": "sha512-i3eMG77UTMD0hZhgRS562pv83RC6ukSAC2GMNWc+9dieh/+jDM5u5YG+NHX6VNDRHQcHwmsTHctP9LhbC3WxVw==", "requires": {} }, "tsconfig-paths": { @@ -19511,29 +19759,30 @@ } }, "typed-array-byte-offset": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.2.tgz", - "integrity": "sha512-Ous0vodHa56FviZucS2E63zkgtgrACj7omjwd/8lTEMEPFFyjfixMZ1ZXenpgCFBBt4EC1J2XsyVS2gkG0eTFA==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.3.tgz", + "integrity": "sha512-GsvTyUHTriq6o/bHcTd0vM7OQ9JEdlvluu9YISaA7+KzDzPaIzEeDFNkTfhdE3MYcNhNi0vq/LlegYgIs5yPAw==", "requires": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.7", "for-each": "^0.3.3", "gopd": "^1.0.1", "has-proto": "^1.0.3", - "is-typed-array": "^1.1.13" + "is-typed-array": "^1.1.13", + "reflect.getprototypeof": "^1.0.6" } }, "typed-array-length": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.6.tgz", - "integrity": "sha512-/OxDN6OtAk5KBpGb28T+HZc2M+ADtvRxXrKKbUwtsLgdoxgX13hyy7ek6bFRl5+aBs2yZzB0c4CnQfAtVypW/g==", + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.7.tgz", + "integrity": "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==", "requires": { "call-bind": "^1.0.7", "for-each": "^0.3.3", "gopd": "^1.0.1", - "has-proto": "^1.0.3", "is-typed-array": "^1.1.13", - "possible-typed-array-names": "^1.0.0" + "possible-typed-array-names": "^1.0.0", + "reflect.getprototypeof": "^1.0.6" } }, "typedarray": { @@ -19696,21 +19945,59 @@ } }, "which-boxed-primitive": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.0.tgz", + "integrity": "sha512-Ei7Miu/AXe2JJ4iNF5j/UphAgRoma4trE6PtisM09bPygb3egMH3YLW/befsWb1A1AxvNSFidOFTB18XtnIIng==", + "requires": { + "is-bigint": "^1.1.0", + "is-boolean-object": "^1.2.0", + "is-number-object": "^1.1.0", + "is-string": "^1.1.0", + "is-symbol": "^1.1.0" + } + }, + "which-builtin-type": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.0.tgz", + "integrity": "sha512-I+qLGQ/vucCby4tf5HsLmGueEla4ZhwTBSqaooS+Y0BuxN4Cp+okmGuV+8mXZ84KDI9BA+oklo+RzKg0ONdSUA==", + "requires": { + "call-bind": "^1.0.7", + "function.prototype.name": "^1.1.6", + "has-tostringtag": "^1.0.2", + "is-async-function": "^2.0.0", + "is-date-object": "^1.0.5", + "is-finalizationregistry": "^1.1.0", + "is-generator-function": "^1.0.10", + "is-regex": "^1.1.4", + "is-weakref": "^1.0.2", + "isarray": "^2.0.5", + "which-boxed-primitive": "^1.0.2", + "which-collection": "^1.0.2", + "which-typed-array": "^1.1.15" + }, + "dependencies": { + "isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==" + } + } + }, + "which-collection": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.0.2.tgz", - "integrity": "sha512-bwZdv0AKLpplFY2KZRX6TvyuN7ojjr7lwkg6ml0roIy9YeuSr7JS372qlNW18UQYzgYK9ziGcerWqZOmEn9VNg==", + "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", + "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", "requires": { - "is-bigint": "^1.0.1", - "is-boolean-object": "^1.1.0", - "is-number-object": "^1.0.4", - "is-string": "^1.0.5", - "is-symbol": "^1.0.3" + "is-map": "^2.0.3", + "is-set": "^2.0.3", + "is-weakmap": "^2.0.2", + "is-weakset": "^2.0.3" } }, "which-typed-array": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.15.tgz", - "integrity": "sha512-oV0jmFtUky6CXfkqehVvBP/LSWJ2sy4vWMioiENyJLePrBO/yKyV9OyJySfAKosh+RYkIl5zJCNZ8/4JncrpdA==", + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.16.tgz", + "integrity": "sha512-g+N+GAWiRj66DngFwHvISJd+ITsyphZvD1vChfVg6cEdnzy53GzB3oy0fUNlvhz7H7+MiqhYr26qxQShCpKTTQ==", "requires": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.7", diff --git a/package.json b/package.json index 1a5d59af29..e0f370be61 100644 --- a/package.json +++ b/package.json @@ -13,8 +13,9 @@ "e2e:linux:run": "lerna run --scope e2e-tests test --", "prepare": "husky", "lint-staged": "lerna run lint-staged", - "watch": "lerna watch -- lerna run build --since", - "build:auth": "cd ./3rd-party/auth && pnpm install && pnpm build" + "build:auth": "cd ./3rd-party/auth && pnpm install && pnpm build", + "bootstrap": "npm run build:auth && lerna bootstrap", + "watch": "lerna watch -- lerna run build --since" }, "engines": { "node": "18.12.1", diff --git a/packages/backend/package-lock.json b/packages/backend/package-lock.json index a5efe739c0..049a39b7e8 100644 --- a/packages/backend/package-lock.json +++ b/packages/backend/package-lock.json @@ -29,7 +29,8 @@ "@libp2p/pnet": "1.0.0-3c8dd5bbf", "@libp2p/utils": "^5.4.9", "@libp2p/websockets": "^8.2.0", - "@localfirst/auth": "file:../../3rd-party/auth/packages/auth", + "@localfirst/auth": "file:../../3rd-party/auth/packages/auth/dist", + "@localfirst/crdx": "file:../../3rd-party/auth/packages/crdx/dist", "@multiformats/multiaddr": "^12.3.0", "@multiformats/multiaddr-to-uri": "^10.1.0", "@nestjs/common": "^10.2.10", @@ -40,6 +41,7 @@ "abortable-iterator": "^3.0.0", "blockstore-fs": "^2.0.0", "blockstore-level": "^2.0.1", + "bs58": "^6.0.0", "class-transformer": "^0.5.1", "class-validator": "^0.14.1", "cli-table": "^0.3.6", @@ -55,6 +57,8 @@ "fastq": "^1.17.1", "fetch-retry": "^6.0.0", "get-port": "^5.1.1", + "getmac": "^6.6.0", + "go-ipfs": "npm:mocked-go-ipfs@0.17.0", "http-server": "^0.12.3", "https-proxy-agent": "^7.0.5", "image-size": "^1.0.1", @@ -82,6 +86,7 @@ "string-replace-loader": "3.1.0", "ts-jest-resolver": "^2.0.0", "uint8arrays": "^5.1.0", + "utf-8-validate": "^5.0.2", "validator": "^13.11.0" }, "devDependencies": { @@ -137,6 +142,25 @@ "node": ">=18" } }, + "../../3rd-party/auth/packages/auth/dist": {}, + "../../3rd-party/auth/packages/crdx": { + "name": "@localfirst/crdx", + "version": "6.0.0-alpha.6", + "license": "MIT", + "dependencies": { + "@herbcaudill/eventemitter42": "^0.3.1", + "@localfirst/crypto": "workspace:*", + "@localfirst/shared": "workspace:*", + "@paralleldrive/cuid2": "^2.2.2", + "@types/lodash-es": "^4.17.12", + "lodash-es": "^4.17.21", + "msgpackr": "^1.10.0" + }, + "engines": { + "node": ">=18" + } + }, + "../../3rd-party/auth/packages/crdx/dist": {}, "../common": { "name": "@quiet/common", "version": "2.0.2-alpha.1", @@ -5355,7 +5379,11 @@ } }, "node_modules/@localfirst/auth": { - "resolved": "../../3rd-party/auth/packages/auth", + "resolved": "../../3rd-party/auth/packages/auth/dist", + "link": true + }, + "node_modules/@localfirst/crdx": { + "resolved": "../../3rd-party/auth/packages/crdx/dist", "link": true }, "node_modules/@multiformats/multiaddr": { @@ -11983,6 +12011,19 @@ "supports-color": "^9.4.0" } }, + "node_modules/bs58": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/bs58/-/bs58-6.0.0.tgz", + "integrity": "sha512-PD0wEnEYg6ijszw/u8s+iI3H17cTymlrwkKhDhPZq+Sokl3AU4htyBFTjAeNAlCCmg0f53g6ih3jATyCKftTfw==", + "dependencies": { + "base-x": "^5.0.0" + } + }, + "node_modules/bs58/node_modules/base-x": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/base-x/-/base-x-5.0.0.tgz", + "integrity": "sha512-sMW3VGSX1QWVFA6l8U62MLKz29rRfpTlYdCqLdpLo1/Yd4zZwSbnUaDfciIAowAqvq7YFnWq9hrhdg1KYgc1lQ==" + }, "node_modules/class-transformer": { "version": "0.5.1", "license": "MIT" @@ -13128,6 +13169,23 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/getmac": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/getmac/-/getmac-6.6.0.tgz", + "integrity": "sha512-o1sq9o5QTfwUyWy7Dao1YGZOI9lN+xzEr9Ul36hyOxFrtuwgLG1ff7oiBEfRDxOrB3jJ2u4jKEs5KMSElyE0cQ==", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://bevry.me/fund" + } + }, + "node_modules/go-ipfs": { + "name": "mocked-go-ipfs", + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/mocked-go-ipfs/-/mocked-go-ipfs-0.17.0.tgz", + "integrity": "sha512-CH0ZWrKHzshQS1NR7cOiyyQmcgbnSVgkqvoJ3DtA4/PxEHsOEb4woHbsVNtbJJmlzAIplsrkb/6wBDxsR6Jdcw==" + }, "node_modules/http-server": { "version": "0.12.3", "license": "MIT", @@ -19510,6 +19568,28 @@ "multiformats": "^13.0.0" } }, + "node_modules/utf-8-validate": { + "version": "5.0.10", + "resolved": "https://registry.npmjs.org/utf-8-validate/-/utf-8-validate-5.0.10.tgz", + "integrity": "sha512-Z6czzLq4u8fPOyx7TU6X3dvUZVvoJmxSQ+IcrlmagKhilxlhZgxPK6C5Jqbkw1IDUmFTM+cz9QDnnLTwDz/2gQ==", + "hasInstallScript": true, + "dependencies": { + "node-gyp-build": "^4.3.0" + }, + "engines": { + "node": ">=6.14.2" + } + }, + "node_modules/utf-8-validate/node_modules/node-gyp-build": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, "node_modules/validator": { "version": "13.12.0", "license": "MIT", @@ -24314,17 +24394,10 @@ } }, "@localfirst/auth": { - "version": "file:../../3rd-party/auth/packages/auth", - "requires": { - "@herbcaudill/eventemitter42": "^0.3.1", - "@localfirst/crdx": "workspace:*", - "@localfirst/crypto": "workspace:*", - "@localfirst/shared": "workspace:*", - "@paralleldrive/cuid2": "^2.2.2", - "lodash-es": "^4.17.21", - "msgpackr": "^1.10.0", - "xstate": "^5.9.1" - } + "version": "file:../../3rd-party/auth/packages/auth/dist" + }, + "@localfirst/crdx": { + "version": "file:../../3rd-party/auth/packages/crdx/dist" }, "@multiformats/multiaddr": { "version": "12.3.1", @@ -28450,6 +28523,21 @@ } } }, + "bs58": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/bs58/-/bs58-6.0.0.tgz", + "integrity": "sha512-PD0wEnEYg6ijszw/u8s+iI3H17cTymlrwkKhDhPZq+Sokl3AU4htyBFTjAeNAlCCmg0f53g6ih3jATyCKftTfw==", + "requires": { + "base-x": "^5.0.0" + }, + "dependencies": { + "base-x": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/base-x/-/base-x-5.0.0.tgz", + "integrity": "sha512-sMW3VGSX1QWVFA6l8U62MLKz29rRfpTlYdCqLdpLo1/Yd4zZwSbnUaDfciIAowAqvq7YFnWq9hrhdg1KYgc1lQ==" + } + } + }, "class-transformer": { "version": "0.5.1" }, @@ -29168,6 +29256,16 @@ "get-port": { "version": "5.1.1" }, + "getmac": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/getmac/-/getmac-6.6.0.tgz", + "integrity": "sha512-o1sq9o5QTfwUyWy7Dao1YGZOI9lN+xzEr9Ul36hyOxFrtuwgLG1ff7oiBEfRDxOrB3jJ2u4jKEs5KMSElyE0cQ==" + }, + "go-ipfs": { + "version": "npm:mocked-go-ipfs@0.17.0", + "resolved": "https://registry.npmjs.org/mocked-go-ipfs/-/mocked-go-ipfs-0.17.0.tgz", + "integrity": "sha512-CH0ZWrKHzshQS1NR7cOiyyQmcgbnSVgkqvoJ3DtA4/PxEHsOEb4woHbsVNtbJJmlzAIplsrkb/6wBDxsR6Jdcw==" + }, "http-server": { "version": "0.12.3", "requires": { @@ -33081,6 +33179,21 @@ "multiformats": "^13.0.0" } }, + "utf-8-validate": { + "version": "5.0.10", + "resolved": "https://registry.npmjs.org/utf-8-validate/-/utf-8-validate-5.0.10.tgz", + "integrity": "sha512-Z6czzLq4u8fPOyx7TU6X3dvUZVvoJmxSQ+IcrlmagKhilxlhZgxPK6C5Jqbkw1IDUmFTM+cz9QDnnLTwDz/2gQ==", + "requires": { + "node-gyp-build": "^4.3.0" + }, + "dependencies": { + "node-gyp-build": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==" + } + } + }, "validator": { "version": "13.12.0" }, diff --git a/packages/backend/package.json b/packages/backend/package.json index f24ad9ef02..7a93c67212 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -93,7 +93,8 @@ "@chainsafe/libp2p-gossipsub": "13.2.0", "@chainsafe/libp2p-noise": "13.0.2", "@chainsafe/libp2p-yamux": "6.0.2", - "@localfirst/auth": "file:../../3rd-party/auth/packages/auth", + "@localfirst/auth": "file:../../3rd-party/auth/packages/auth/dist", + "@localfirst/crdx": "file:../../3rd-party/auth/packages/crdx/dist", "@nestjs/common": "^10.2.10", "@nestjs/core": "^10.2.10", "@nestjs/platform-express": "^10.2.10", @@ -105,6 +106,7 @@ "abortable-iterator": "^3.0.0", "blockstore-fs": "^2.0.0", "blockstore-level": "^2.0.1", + "bs58": "^6.0.0", "class-transformer": "^0.5.1", "class-validator": "^0.14.1", "cli-table": "^0.3.6", @@ -123,6 +125,8 @@ "helia": "4.0.2", "@helia/unixfs": "3.0.1", "@helia/block-brokers": "2.0.3", + "getmac": "^6.6.0", + "go-ipfs": "npm:mocked-go-ipfs@0.17.0", "http-server": "^0.12.3", "https-proxy-agent": "^7.0.5", "image-size": "^1.0.1", @@ -168,6 +172,7 @@ "string-replace-loader": "3.1.0", "ts-jest-resolver": "^2.0.0", "uint8arrays": "^5.1.0", + "utf-8-validate": "^5.0.2", "validator": "^13.11.0" }, "overrides": { diff --git a/packages/backend/src/nest/auth/services/chainServiceBase.ts b/packages/backend/src/nest/auth/services/chainServiceBase.ts new file mode 100644 index 0000000000..164ccbd050 --- /dev/null +++ b/packages/backend/src/nest/auth/services/chainServiceBase.ts @@ -0,0 +1,14 @@ +import { SigChain } from '../sigchain' +import { createLogger } from '../../common/logger' + +const logger = createLogger('auth:baseChainService') + +class ChainServiceBase { + protected constructor(protected sigChain: SigChain) {} + + public static init(sigChain: SigChain, ...params: any[]): ChainServiceBase { + throw new Error('init not implemented') + } +} + +export { ChainServiceBase } diff --git a/packages/backend/src/nest/auth/services/crypto/crypto.service.ts b/packages/backend/src/nest/auth/services/crypto/crypto.service.ts new file mode 100644 index 0000000000..0e84d1c988 --- /dev/null +++ b/packages/backend/src/nest/auth/services/crypto/crypto.service.ts @@ -0,0 +1,152 @@ +/** + * Handles invite-related chain operations + */ +import * as bs58 from 'bs58' + +import { EncryptedAndSignedPayload, EncryptedPayload, EncryptionScope, EncryptionScopeType } from './types' +import { ChainServiceBase } from '../chainServiceBase' +import { SigChain } from '../../sigchain' +import { asymmetric, Base58, Keyset, LocalUserContext, Member, SignedEnvelope } from '@localfirst/auth' +import { DEFAULT_SEARCH_OPTIONS, MemberSearchOptions } from '../members/types' +import { createLogger } from '../../../common/logger' + +const logger = createLogger('auth:cryptoService') + +class CryptoService extends ChainServiceBase { + public static init(sigChain: SigChain): CryptoService { + return new CryptoService(sigChain) + } + + // TODO: Can we get other members' keys by generation? + public getPublicKeysForMembersById( + memberIds: string[], + searchOptions: MemberSearchOptions = DEFAULT_SEARCH_OPTIONS + ): Keyset[] { + const members = this.sigChain.users.getUsersById(memberIds, searchOptions) + return members.map((member: Member) => { + return member.keys + }) + } + + public encryptAndSign(message: any, scope: EncryptionScope, context: LocalUserContext): EncryptedAndSignedPayload { + let encryptedPayload: EncryptedPayload + switch (scope.type) { + // Symmetrical Encryption Types + case EncryptionScopeType.CHANNEL: + case EncryptionScopeType.ROLE: + case EncryptionScopeType.TEAM: + encryptedPayload = this.symEncrypt(message, scope) + break + // Asymmetrical Encryption Types + case EncryptionScopeType.USER: + encryptedPayload = this.asymUserEncrypt(message, scope, context) + break + // Unknown Type + default: + throw new Error(`Unknown encryption type ${scope.type} provided!`) + } + + const signature = this.sigChain.team.sign(encryptedPayload.contents) + + return { + encrypted: encryptedPayload, + signature, + ts: Date.now(), + username: context.user.userName, + } + } + + private symEncrypt(message: any, scope: EncryptionScope): EncryptedPayload { + if (scope.type != EncryptionScopeType.TEAM && scope.name == null) { + throw new Error(`Must provide a scope name when encryption scope is set to ${scope.type}`) + } + + const envelope = this.sigChain.team.encrypt(message, scope.name) + return { + contents: bs58.default.encode(envelope.contents) as Base58, + scope: { + ...scope, + generation: envelope.recipient.generation, + }, + } + } + + private asymUserEncrypt(message: any, scope: EncryptionScope, context: LocalUserContext): EncryptedPayload { + if (scope.name == null) { + throw new Error(`Must provide a user ID when encryption scope is set to ${scope.type}`) + } + + const recipientKeys = this.getPublicKeysForMembersById([scope.name]) + const recipientKey = recipientKeys[0].encryption + const senderKey = context.user.keys.encryption.secretKey + const generation = recipientKeys[0].generation + + const encryptedContents = asymmetric.encrypt({ + secret: message, + senderSecretKey: senderKey, + recipientPublicKey: recipientKey, + }) + + return { + contents: encryptedContents, + scope: { + ...scope, + generation, + }, + } + } + + public decryptAndVerify(encrypted: EncryptedPayload, signature: SignedEnvelope, context: LocalUserContext): any { + const isValid = this.sigChain.team.verify(signature) + if (!isValid) { + throw new Error(`Couldn't verify signature on message`) + } + + switch (encrypted.scope.type) { + // Symmetrical Encryption Types + case EncryptionScopeType.CHANNEL: + case EncryptionScopeType.ROLE: + case EncryptionScopeType.TEAM: + return this.symDecrypt(encrypted) + // Asymmetrical Encryption Types + case EncryptionScopeType.USER: + return this.asymUserDecrypt(encrypted, signature, context) + // Unknown Type + default: + throw new Error(`Unknown encryption scope type ${encrypted.scope.type}`) + } + } + + private symDecrypt(encrypted: EncryptedPayload): any { + if (encrypted.scope.type !== EncryptionScopeType.TEAM && encrypted.scope.name == null) { + throw new Error(`Must provide a scope name when encryption scope is set to ${encrypted.scope.type}`) + } + + return this.sigChain.team.decrypt({ + contents: bs58.default.decode(encrypted.contents), + recipient: { + ...encrypted.scope, + // you don't need a name on the scope when encrypting but you need one for decrypting because of how LFA searches for keys in lockboxes + name: encrypted.scope.type === EncryptionScopeType.TEAM ? EncryptionScopeType.TEAM : encrypted.scope.name!, + }, + }) + } + + private asymUserDecrypt(encrypted: EncryptedPayload, signature: SignedEnvelope, context: LocalUserContext): any { + if (encrypted.scope.name == null) { + throw new Error(`Must provide a user ID when encryption scope is set to ${encrypted.scope.type}`) + } + + const senderKeys = this.sigChain.crypto.getPublicKeysForMembersById([signature.author.name]) + const recipientKey = context.user.keys.encryption.secretKey + const senderKey = senderKeys[0].encryption + + return asymmetric.decrypt({ + cipher: encrypted.contents, + senderPublicKey: senderKey, + recipientSecretKey: recipientKey, + }) + } +} + +export { CryptoService } diff --git a/packages/backend/src/nest/auth/services/crypto/types.ts b/packages/backend/src/nest/auth/services/crypto/types.ts new file mode 100644 index 0000000000..3e15f0d7e0 --- /dev/null +++ b/packages/backend/src/nest/auth/services/crypto/types.ts @@ -0,0 +1,27 @@ +import { Base58, SignedEnvelope } from '@localfirst/auth' + +export enum EncryptionScopeType { + ROLE = 'ROLE', + CHANNEL = 'CHANNEL', + USER = 'USER', + TEAM = 'TEAM', +} + +export type EncryptionScope = { + type: EncryptionScopeType + name?: string +} + +export type EncryptedPayload = { + contents: Base58 + scope: EncryptionScope & { + generation: number + } +} + +export type EncryptedAndSignedPayload = { + encrypted: EncryptedPayload + signature: SignedEnvelope + ts: number + username: string +} diff --git a/packages/backend/src/nest/auth/services/invites/invite.service.spec.ts b/packages/backend/src/nest/auth/services/invites/invite.service.spec.ts new file mode 100644 index 0000000000..0b5cbe940a --- /dev/null +++ b/packages/backend/src/nest/auth/services/invites/invite.service.spec.ts @@ -0,0 +1,97 @@ +import { jest } from '@jest/globals' +import { SigChain } from '../../sigchain' +import { SigChainService } from '../../sigchain.service' +import { createLogger } from '../../../common/logger' +import { device, InviteResult, LocalUserContext } from '@localfirst/auth' +import { RoleName } from '..//roles/roles' +import { UserService } from '../members/user.service' +import { InviteService } from './invite.service' +import { DeviceService } from '../members/device.service' + +const logger = createLogger('auth:services:invite.spec') + +describe('invites', () => { + let adminSigChain: SigChain + let newMemberSigChain: SigChain + it('should initialize a new sigchain and be admin', () => { + adminSigChain = SigChain.create('test', 'user') + expect(adminSigChain).toBeDefined() + expect(adminSigChain.context).toBeDefined() + expect(adminSigChain.team.teamName).toBe('test') + expect(adminSigChain.context.user.userName).toBe('user') + expect(adminSigChain.roles.amIMemberOfRole(adminSigChain.context, RoleName.ADMIN)).toBe(true) + expect(adminSigChain.roles.amIMemberOfRole(adminSigChain.context, RoleName.MEMBER)).toBe(true) + }) + it('admin should generate an invite and it be added to team graph', () => { + const newInvite = adminSigChain.invites.createUserInvite() + expect(newInvite).toBeDefined() + expect(adminSigChain.invites.getAllInvites().length).toBe(1) + expect(adminSigChain.invites.getById(newInvite.id)).toBeDefined() + }) + it('admin should generate an invite seed and create a new user from it', () => { + const invite = adminSigChain.invites.createUserInvite() + expect(invite).toBeDefined() + const prospectiveMember = UserService.createFromInviteSeed('user2', invite.seed) + const inviteProof = InviteService.generateProof(invite.seed) + expect(inviteProof).toBeDefined() + expect(adminSigChain.invites.validateProof(inviteProof)).toBe(true) + expect(prospectiveMember).toBeDefined() + newMemberSigChain = SigChain.join( + prospectiveMember.context, + adminSigChain.team.save(), + adminSigChain.team.teamKeyring() + ) + expect(newMemberSigChain).toBeDefined() + expect(newMemberSigChain.context).toBeDefined() + expect(newMemberSigChain.context.user.userName).toBe('user2') + expect(newMemberSigChain.context.user.userId).not.toBe(adminSigChain.context.user.userId) + expect(newMemberSigChain.roles.amIMemberOfRole(newMemberSigChain.context, RoleName.MEMBER)).toBe(false) + expect(newMemberSigChain.roles.amIMemberOfRole(newMemberSigChain.context, RoleName.ADMIN)).toBe(false) + expect( + adminSigChain.invites.admitMemberFromInvite( + inviteProof, + newMemberSigChain.context.user.userName, + newMemberSigChain.context.user.userId, + newMemberSigChain.context.user.keys + ) + ).toBeDefined() + expect(adminSigChain.roles.amIMemberOfRole(newMemberSigChain.context, RoleName.MEMBER)).toBe(true) + }) + it('admin should be able to revoke an invite', () => { + const inviteToRevoke = adminSigChain.invites.createUserInvite() + expect(inviteToRevoke).toBeDefined() + adminSigChain.invites.revoke(inviteToRevoke.id) + const InvalidInviteProof = InviteService.generateProof(inviteToRevoke.seed) + expect(InvalidInviteProof).toBeDefined() + expect(adminSigChain.invites.validateProof(InvalidInviteProof)).toBe(false) + }) + it('admitting a new member with an invalid invite should fail', () => { + const invalidInviteProof = InviteService.generateProof('invalidseed') + expect(invalidInviteProof).toBeDefined() + expect(adminSigChain.invites.validateProof(invalidInviteProof)).toBe(false) + const prospectiveMember = UserService.createFromInviteSeed('user3', 'invalidseed') + expect(prospectiveMember).toBeDefined() + const newSigchain = SigChain.join( + prospectiveMember.context, + adminSigChain.team.save(), + adminSigChain.team.teamKeyring() + ) + expect(() => { + adminSigChain.invites.admitMemberFromInvite( + invalidInviteProof, + prospectiveMember.context.user.userName, + prospectiveMember.context.user.userId, + prospectiveMember.publicKeys + ) + }).toThrowError() + }) + it('should invite device', () => { + const newDevice = DeviceService.generateDeviceForUser(adminSigChain.context.user.userId) + const deviceInvite = adminSigChain.invites.createDeviceInvite() + const inviteProof = InviteService.generateProof(deviceInvite.seed) + expect(inviteProof).toBeDefined() + expect(adminSigChain.invites.validateProof(inviteProof)).toBe(true) + adminSigChain.invites.admitDeviceFromInvite(inviteProof, DeviceService.redactDevice(newDevice)) + expect(adminSigChain.team.hasDevice(newDevice.deviceId)).toBe(true) + }) +}) diff --git a/packages/backend/src/nest/auth/services/invites/invite.service.ts b/packages/backend/src/nest/auth/services/invites/invite.service.ts new file mode 100644 index 0000000000..dbe220e893 --- /dev/null +++ b/packages/backend/src/nest/auth/services/invites/invite.service.ts @@ -0,0 +1,98 @@ +/** + * Handles invite-related chain operations + */ + +import { ChainServiceBase } from '../chainServiceBase' +import { ValidationResult } from '@localfirst/crdx' +import { + Base58, + FirstUseDevice, + InvitationState, + InviteResult, + Keyset, + ProofOfInvitation, + UnixTimestamp, +} from '@localfirst/auth' +import { SigChain } from '../../sigchain' +import { RoleName } from '../roles/roles' +import { createLogger } from '../../../common/logger' + +const logger = createLogger('auth:inviteService') + +export const DEFAULT_MAX_USES = 1 +export const DEFAULT_INVITATION_VALID_FOR_MS = 604_800_000 // 1 week + +class InviteService extends ChainServiceBase { + public static init(sigChain: SigChain): InviteService { + return new InviteService(sigChain) + } + + public createUserInvite( + validForMs: number = DEFAULT_INVITATION_VALID_FOR_MS, + maxUses: number = DEFAULT_MAX_USES, + seed?: string + ): InviteResult { + const expiration = (Date.now() + validForMs) as UnixTimestamp + const invitation: InviteResult = this.sigChain.team.inviteMember({ + seed, + expiration, + maxUses, + }) + return invitation + } + + public createDeviceInvite(validForMs: number = DEFAULT_INVITATION_VALID_FOR_MS, seed?: string): InviteResult { + const expiration = (Date.now() + validForMs) as UnixTimestamp + const invitation: InviteResult = this.sigChain.team.inviteDevice({ + expiration, + seed, + }) + return invitation + } + + public revoke(id: string) { + this.sigChain.team.revokeInvitation(id) + } + + public getById(id: Base58): InvitationState { + return this.sigChain.team.getInvitation(id) + } + + public static generateProof(seed: string): ProofOfInvitation { + return SigChain.lfa.invitation.generateProof(seed) + } + + public validateProof(proof: ProofOfInvitation): boolean { + const validationResult = this.sigChain.team.validateInvitation(proof) as ValidationResult + if (!validationResult.isValid) { + logger.warn(`Proof was invalid or was on an invalid invitation`, validationResult.error) + return false + } + return true + } + + public admitUser(proof: ProofOfInvitation, username: string, publicKeys: Keyset) { + this.sigChain.team.admitMember(proof, publicKeys, username) + } + + public admitMemberFromInvite(proof: ProofOfInvitation, username: string, userId: string, publicKeys: Keyset): string { + this.sigChain.team.admitMember(proof, publicKeys, username) + this.sigChain.roles.addMember(userId, RoleName.MEMBER) + return username + } + + public admitDeviceFromInvite(proof: ProofOfInvitation, firstUseDevice: FirstUseDevice): void { + this.sigChain.team.admitDevice(proof, firstUseDevice) + } + + public getAllInvites(): InvitationState[] { + const inviteMap = this.sigChain.team.invitations() + const invites: InvitationState[] = [] + for (const invite of Object.entries(inviteMap)) { + invites.push(invite[1]) + } + return invites + } +} + +export { InviteService } diff --git a/packages/backend/src/nest/auth/services/members/device.service.spec.ts b/packages/backend/src/nest/auth/services/members/device.service.spec.ts new file mode 100644 index 0000000000..8b917e9d8d --- /dev/null +++ b/packages/backend/src/nest/auth/services/members/device.service.spec.ts @@ -0,0 +1,36 @@ +import { SigChain } from '../../sigchain' +import { createLogger } from '../../../common/logger' +import { DeviceWithSecrets, LocalUserContext } from '3rd-party/auth/packages/auth/dist' +import { RoleName } from '..//roles/roles' +import { DeviceService } from './device.service' + +const logger = createLogger('auth:services:device.spec') + +describe('invites', () => { + let adminSigChain: SigChain + let newDevice: DeviceWithSecrets + + it('should initialize a new sigchain and be admin', () => { + adminSigChain = SigChain.create('test', 'user') + expect(adminSigChain).toBeDefined() + expect(adminSigChain.context).toBeDefined() + expect(adminSigChain.team.teamName).toBe('test') + expect(adminSigChain.context.user.userName).toBe('user') + expect(adminSigChain.roles.amIMemberOfRole(adminSigChain.context, RoleName.ADMIN)).toBe(true) + expect(adminSigChain.roles.amIMemberOfRole(adminSigChain.context, RoleName.MEMBER)).toBe(true) + }) + it('sigchain should contain admin device', () => { + const adminDeviceName = DeviceService.determineDeviceName() + adminSigChain.team.hasDevice(adminSigChain.context.device.deviceId) + }) + it('should generate a new device', () => { + newDevice = DeviceService.generateDeviceForUser(adminSigChain.context.user.userId) + expect(newDevice).toBeDefined() + }) + it('should redactDevice', () => { + const redactedDevice = DeviceService.redactDevice(newDevice) + expect(redactedDevice).toBeDefined() + expect(redactedDevice.deviceId).toBe(newDevice.deviceId) + expect(redactedDevice.deviceName).toBe(newDevice.deviceName) + }) +}) diff --git a/packages/backend/src/nest/auth/services/members/device.service.ts b/packages/backend/src/nest/auth/services/members/device.service.ts new file mode 100644 index 0000000000..18dff4cf5b --- /dev/null +++ b/packages/backend/src/nest/auth/services/members/device.service.ts @@ -0,0 +1,47 @@ +/** + * Handles device-related chain operations + */ + +import getMAC from 'getmac' +import { ChainServiceBase } from '../chainServiceBase' +import { Device, DeviceWithSecrets, redactDevice } from '@localfirst/auth' +import { SigChain } from '../../sigchain' +import { createLogger } from '../../../common/logger' + +const logger = createLogger('auth:deviceService') +class DeviceService extends ChainServiceBase { + public static init(sigChain: SigChain): DeviceService { + return new DeviceService(sigChain) + } + + /** + * Generate a brand new QuietDevice for a given User ID + * + * @param userId User ID that this device is associated with + * @returns A newly generated QuietDevice instance + */ + public static generateDeviceForUser(userId: string): DeviceWithSecrets { + const params = { + userId, + deviceName: DeviceService.determineDeviceName(), + } + + return SigChain.lfa.createDevice(params) + } + + /** + * Get an identifier for the current device + * + * @returns Formatted MAC address of the current device + */ + public static determineDeviceName(): string { + const mac = getMAC() + return mac.replace(/:/g, '') + } + + public static redactDevice(device: DeviceWithSecrets): Device { + return redactDevice(device) + } +} + +export { DeviceService } diff --git a/packages/backend/src/nest/auth/services/members/types.ts b/packages/backend/src/nest/auth/services/members/types.ts new file mode 100644 index 0000000000..d499756838 --- /dev/null +++ b/packages/backend/src/nest/auth/services/members/types.ts @@ -0,0 +1,14 @@ +import { Keyset, LocalUserContext, ProofOfInvitation } from '@localfirst/auth' + +export type MemberSearchOptions = { + includeRemoved: boolean + throwOnMissing: boolean +} + +export type ProspectiveUser = { + context: LocalUserContext + inviteProof: ProofOfInvitation + publicKeys: Keyset +} + +export const DEFAULT_SEARCH_OPTIONS: MemberSearchOptions = { includeRemoved: false, throwOnMissing: true } diff --git a/packages/backend/src/nest/auth/services/members/user.service.spec.ts b/packages/backend/src/nest/auth/services/members/user.service.spec.ts new file mode 100644 index 0000000000..cf11f971b1 --- /dev/null +++ b/packages/backend/src/nest/auth/services/members/user.service.spec.ts @@ -0,0 +1,46 @@ +import { jest } from '@jest/globals' +import { SigChain } from '../../sigchain' +import { SigChainService } from '../../sigchain.service' +import { createLogger } from '../../../common/logger' +import { device, InviteResult, LocalUserContext } from '@localfirst/auth' +import { RoleName } from '..//roles/roles' +import { UserService } from './user.service' +import { DeviceService } from '../members/device.service' + +const logger = createLogger('auth:services:invite.spec') + +describe('invites', () => { + let adminSigChain: SigChain + + it('should initialize a new sigchain and be admin', () => { + adminSigChain = SigChain.create('test', 'user') + expect(adminSigChain).toBeDefined() + expect(adminSigChain.context).toBeDefined() + expect(adminSigChain.team.teamName).toBe('test') + expect(adminSigChain.context.user.userName).toBe('user') + expect(adminSigChain.roles.amIMemberOfRole(adminSigChain.context, RoleName.ADMIN)).toBe(true) + expect(adminSigChain.roles.amIMemberOfRole(adminSigChain.context, RoleName.MEMBER)).toBe(true) + }) + it('should get keys', () => { + const keys = adminSigChain.users.getKeys() + expect(keys).toBeDefined() + }) + it('get all members', () => { + const users = adminSigChain.users.getAllUsers() + expect(users).toBeDefined() + }) + it('get admin member by id', () => { + const users = adminSigChain.users.getUsersById([adminSigChain.context.user.userId]) + expect(users.map(u => u.userId)).toContain(adminSigChain.context.user.userId) + }) + it('get admin member by name', () => { + const user = adminSigChain.users.getUserByName(adminSigChain.context.user.userName) + expect(user!.userName).toEqual(adminSigChain.context.user.userName) + }) + it('should redact user', () => { + const redactedUser = UserService.redactUser(adminSigChain.context.user) + expect(redactedUser).toBeDefined() + expect(redactedUser.userId).toBe(adminSigChain.context.user.userId) + expect(redactedUser.userName).toBe(adminSigChain.context.user.userName) + }) +}) diff --git a/packages/backend/src/nest/auth/services/members/user.service.ts b/packages/backend/src/nest/auth/services/members/user.service.ts new file mode 100644 index 0000000000..6eca4c7917 --- /dev/null +++ b/packages/backend/src/nest/auth/services/members/user.service.ts @@ -0,0 +1,86 @@ +/** + * Handles user-related chain operations + */ + +//import { KeyMap } from '../../../../../../packages/auth/dist/team/selectors/keyMap' +import { ChainServiceBase } from '../chainServiceBase' +import { ProspectiveUser, MemberSearchOptions, DEFAULT_SEARCH_OPTIONS } from './types' +import { DeviceWithSecrets, LocalUserContext, Member, User, UserWithSecrets } from '@localfirst/auth' +import { SigChain } from '../../sigchain' +import { DeviceService } from './device.service' +import { InviteService } from '../invites/invite.service' +import { KeyMap } from '@localfirst/auth/team/selectors/keyMap' +import { createLogger } from '../../../common/logger' + +const logger = createLogger('auth:userService') + +class UserService extends ChainServiceBase { + public static init(sigChain: SigChain): UserService { + return new UserService(sigChain) + } + + /** + * Generates a brand new QuietUser instance with an initial device from a given username + * + * @param name The username + * @param id Optionally specify the user's ID (otherwise autogenerate) + * @returns New QuietUser instance with an initial device + */ + public static create(name: string, id?: string): LocalUserContext { + const user: UserWithSecrets = SigChain.lfa.createUser(name, id) + const device: DeviceWithSecrets = DeviceService.generateDeviceForUser(user.userId) + + return { + user, + device, + } + } + + /** + * Generates a new prospective user from an invite seed + * + * @param name The username + * @param seed The invite seed + * @returns ProspectiveUser instance + */ + public static createFromInviteSeed(name: string, seed: string): ProspectiveUser { + const context = this.create(name) + const inviteProof = InviteService.generateProof(seed) + const publicKeys = UserService.redactUser(context.user).keys + + return { + context, + inviteProof, + publicKeys, + } + } + + /** + * Get + */ + public getKeys(): KeyMap { + return this.sigChain.team.allKeys() + } + + public getAllUsers(): Member[] { + return this.sigChain.team.members() + } + + public getUsersById(memberIds: string[], options: MemberSearchOptions = DEFAULT_SEARCH_OPTIONS): Member[] { + if (memberIds.length === 0) { + return [] + } + + return this.sigChain.team.members(memberIds, options) + } + + public getUserByName(memberName: string): Member | undefined { + return this.getAllUsers().find(member => member.userName === memberName) + } + + public static redactUser(user: UserWithSecrets): User { + return SigChain.lfa.redactUser(user) + } +} + +export { UserService } diff --git a/packages/backend/src/nest/auth/services/roles/channel.service.spec.ts b/packages/backend/src/nest/auth/services/roles/channel.service.spec.ts new file mode 100644 index 0000000000..3da5a1a312 --- /dev/null +++ b/packages/backend/src/nest/auth/services/roles/channel.service.spec.ts @@ -0,0 +1,89 @@ +import { SigChain } from '../../sigchain' +import { createLogger } from '../../../common/logger' +import { LocalUserContext } from '@localfirst/auth' +import { RoleName, Channel } from './roles' +import { UserService } from '../members/user.service' +import { InviteService } from '../invites/invite.service' + +const logger = createLogger('auth:services:invite.spec') + +const privateChannelName = 'testChannel' + +describe('invites', () => { + let adminSigChain: SigChain + let newMemberSigChain: SigChain + + it('should initialize a new sigchain and be admin', () => { + adminSigChain = SigChain.create('test', 'user') + expect(adminSigChain).toBeDefined() + expect(adminSigChain.context).toBeDefined() + expect(adminSigChain.team.teamName).toBe('test') + expect(adminSigChain.context.user.userName).toBe('user') + expect(adminSigChain.roles.amIMemberOfRole(adminSigChain.context, RoleName.ADMIN)).toBe(true) + expect(adminSigChain.roles.amIMemberOfRole(adminSigChain.context, RoleName.MEMBER)).toBe(true) + }) + it('should create a private channel', () => { + const privateChannel = adminSigChain.channels.createPrivateChannel(privateChannelName, adminSigChain.context) + expect(privateChannel).toBeDefined() + }) + it('admin should generate an invite seed and admit a new user from it', () => { + const invite = adminSigChain.invites.createUserInvite() + expect(invite).toBeDefined() + const prospectiveMember = UserService.createFromInviteSeed('user2', invite.seed) + const inviteProof = InviteService.generateProof(invite.seed) + expect(inviteProof).toBeDefined() + expect(adminSigChain.invites.validateProof(inviteProof)).toBe(true) + expect(prospectiveMember).toBeDefined() + newMemberSigChain = SigChain.join( + prospectiveMember.context, + adminSigChain.team.save(), + adminSigChain.team.teamKeyring() + ) + expect(newMemberSigChain).toBeDefined() + expect(newMemberSigChain.context).toBeDefined() + expect(newMemberSigChain.context.user.userName).toBe('user2') + expect(newMemberSigChain.context.user.userId).not.toBe(adminSigChain.context.user.userId) + expect(newMemberSigChain.roles.amIMemberOfRole(newMemberSigChain.context, RoleName.MEMBER)).toBe(false) + expect(newMemberSigChain.roles.amIMemberOfRole(newMemberSigChain.context, RoleName.ADMIN)).toBe(false) + expect( + adminSigChain.invites.admitMemberFromInvite( + inviteProof, + newMemberSigChain.context.user.userName, + newMemberSigChain.context.user.userId, + newMemberSigChain.context.user.keys + ) + ).toBeDefined() + expect(adminSigChain.roles.amIMemberOfRole(newMemberSigChain.context, RoleName.MEMBER)).toBe(true) + }) + it('should add the new member to the private channel', () => { + const privateChannel = adminSigChain.channels.getChannel(privateChannelName, adminSigChain.context) + adminSigChain.channels.addMemberToPrivateChannel(newMemberSigChain.context.user.userId, privateChannel.channelName) + expect( + adminSigChain.channels.memberInChannel(newMemberSigChain.context.user.userId, privateChannel.channelName) + ).toBe(true) + }) + it('should remove the new member from the private channel', () => { + const privateChannel = adminSigChain.channels.getChannel(privateChannelName, adminSigChain.context) + adminSigChain.channels.revokePrivateChannelMembership( + newMemberSigChain.context.user.userId, + privateChannel.channelName + ) + expect(adminSigChain.channels.getChannels(newMemberSigChain.context, true).length).toBe(0) + expect( + adminSigChain.channels.memberInChannel(newMemberSigChain.context.user.userId, privateChannel.channelName) + ).toBe(false) + }) + it('should delete channel', () => { + const privateChannel = adminSigChain.channels.getChannel(privateChannelName, adminSigChain.context) + adminSigChain.channels.deletePrivateChannel(privateChannel.channelName) + expect(adminSigChain.channels.getChannels(adminSigChain.context).length).toBe(0) + }) + it('should create new channel and then leave it', () => { + const channel = adminSigChain.channels.createPrivateChannel(privateChannelName, adminSigChain.context) + expect(channel).toBeDefined() + adminSigChain.channels.leaveChannel(channel.channelName, adminSigChain.context) + expect(adminSigChain.channels.memberInChannel(adminSigChain.context.user.userId, channel.channelName)).toBe(false) + expect(adminSigChain.channels.getChannels(adminSigChain.context).length).toBe(1) + expect(adminSigChain.channels.getChannels(adminSigChain.context, true).length).toBe(0) + }) +}) diff --git a/packages/backend/src/nest/auth/services/roles/channel.service.ts b/packages/backend/src/nest/auth/services/roles/channel.service.ts new file mode 100644 index 0000000000..036c37d87b --- /dev/null +++ b/packages/backend/src/nest/auth/services/roles/channel.service.ts @@ -0,0 +1,103 @@ +/** + * Handles channel-related chain operations + */ + +import { LocalUserContext, Role } from '@localfirst/auth' +import { SigChain } from '../../sigchain' +import { ChainServiceBase } from '../chainServiceBase' +import { Channel, QuietRole } from './roles' +import { createLogger } from '../../../common/logger' + +const logger = createLogger('auth:channelService') + +const CHANNEL_ROLE_KEY_PREFIX = 'priv_chan_' + +class ChannelService extends ChainServiceBase { + public static init(sigChain: SigChain): ChannelService { + return new ChannelService(sigChain) + } + + // TODO: figure out permissions + public createPrivateChannel(channelName: string, context: LocalUserContext): Channel { + logger.info(`Creating private channel role with name ${channelName}`) + this.sigChain.roles.create(ChannelService.getPrivateChannelRoleName(channelName)) + this.addMemberToPrivateChannel(context.user.userId, channelName) + + return this.getChannel(channelName, context) + } + + public addMemberToPrivateChannel(userId: string, channelName: string) { + logger.info(`Adding member with ID ${userId} to private channel role with name ${channelName}`) + this.sigChain.roles.addMember(userId, ChannelService.getPrivateChannelRoleName(channelName)) + } + + public revokePrivateChannelMembership(userId: string, channelName: string) { + logger.info(`Removing member with ID ${userId} from private channel with name ${channelName}`) + this.sigChain.roles.revokeMembership(userId, ChannelService.getPrivateChannelRoleName(channelName)) + } + + public deletePrivateChannel(channelName: string) { + logger.info(`Deleting private channel with name ${channelName}`) + this.sigChain.roles.delete(ChannelService.getPrivateChannelRoleName(channelName)) + } + + public leaveChannel(channelName: string, context: LocalUserContext) { + logger.info(`Leaving private channel with name ${channelName}`) + this.revokePrivateChannelMembership(context.user.userId, channelName) + } + + public getChannel(channelName: string, context: LocalUserContext): Channel { + const role = this.sigChain.roles.getRole(ChannelService.getPrivateChannelRoleName(channelName), context) + return this.roleToChannel(role, channelName, context) + } + + public getChannels(context: LocalUserContext, haveAccessOnly: boolean = false): Channel[] { + const allRoles = this.sigChain.roles.getAllRoles(context, haveAccessOnly) + const allChannels = allRoles + .filter((role: QuietRole) => this.isRoleChannel(context, role.roleName)) + .map((role: QuietRole) => + this.roleToChannel(role, ChannelService.getPrivateChannelNameFromRoleName(role.roleName), context) + ) + + return allChannels + } + + public memberInChannel(userId: string, channelName: string): boolean { + const roleName = ChannelService.getPrivateChannelRoleName(channelName) + return this.sigChain.roles.memberHasRole(userId, roleName) + } + + public amIInChannel(context: LocalUserContext, channelName: string): boolean { + return this.memberInChannel(context.user.userId, channelName) + } + + public isRoleChannel(context: LocalUserContext, roleName: string): boolean + public isRoleChannel(context: LocalUserContext, role: QuietRole | Role): boolean + public isRoleChannel(context: LocalUserContext, roleNameOrRole: string | QuietRole | Role): boolean { + let roleName: string + if (typeof roleNameOrRole === 'string') { + roleName = roleNameOrRole + } else { + roleName = roleNameOrRole.roleName + } + + return roleName.startsWith(CHANNEL_ROLE_KEY_PREFIX) + } + + private roleToChannel(role: QuietRole, channelName: string, context: LocalUserContext): Channel { + return { + ...role, + channelName, + } as Channel + } + + public static getPrivateChannelRoleName(channelName: string): string { + return `${CHANNEL_ROLE_KEY_PREFIX}${channelName}` + } + + public static getPrivateChannelNameFromRoleName(roleName: string): string { + return roleName.split(CHANNEL_ROLE_KEY_PREFIX)[1] + } +} + +export { ChannelService } diff --git a/packages/backend/src/nest/auth/services/roles/permissions.ts b/packages/backend/src/nest/auth/services/roles/permissions.ts new file mode 100644 index 0000000000..2ee83d74b5 --- /dev/null +++ b/packages/backend/src/nest/auth/services/roles/permissions.ts @@ -0,0 +1,3 @@ +export enum Permissions { + MODIFIABLE_MEMBERSHIP = 'modifiable-membership', +} diff --git a/packages/backend/src/nest/auth/services/roles/role.service.ts b/packages/backend/src/nest/auth/services/roles/role.service.ts new file mode 100644 index 0000000000..80154bbfc5 --- /dev/null +++ b/packages/backend/src/nest/auth/services/roles/role.service.ts @@ -0,0 +1,109 @@ +/** + * Handles role-related chain operations + */ + +import { SigChain } from '../../sigchain' +import { ChainServiceBase } from '../chainServiceBase' +import { Permissions } from './permissions' +import { QuietRole, RoleName } from './roles' +import { LocalUserContext, Member, PermissionsMap, Role } from '@localfirst/auth' +import { createLogger } from '../../../common/logger' +import { QuietLogger } from '@quiet/logger' + +class RoleService extends ChainServiceBase { + private readonly logger: QuietLogger + + constructor(sigChain: SigChain) { + super(sigChain) + this.logger = createLogger(`auth:roleService(${sigChain.team.teamName})`) + } + + public static init(sigChain: SigChain): RoleService { + return new RoleService(sigChain) + } + + // TODO: figure out permissions + public create(roleName: RoleName | string, permissions: PermissionsMap = {}, staticMembership: boolean = false) { + this.logger.info(`Adding new role with name ${roleName}`) + if (!staticMembership) { + permissions[Permissions.MODIFIABLE_MEMBERSHIP] = true + } + + const role: Role = { + roleName, + permissions, + } + + this.sigChain.team.addRole(role) + } + + // TODO: figure out permissions + public createWithMembers( + roleName: RoleName | string, + memberIdsForRole: string[], + permissions: PermissionsMap = {}, + staticMembership: boolean = false + ) { + this.create(roleName, permissions, staticMembership) + for (const memberId of memberIdsForRole) { + this.addMember(memberId, roleName) + } + } + + public addMember(memberId: string, roleName: string) { + this.logger.info(`Adding member with ID ${memberId} to role ${roleName}`) + this.sigChain.team.addMemberRole(memberId, roleName) + } + + public revokeMembership(memberId: string, roleName: string) { + this.logger.info(`Revoking role ${roleName} for member with ID ${memberId}`) + this.sigChain.team.removeMemberRole(memberId, roleName) + } + + public delete(roleName: string) { + this.logger.info(`Removing role with name ${roleName}`) + this.sigChain.team.removeRole(roleName) + } + + public getRole(roleName: string, context: LocalUserContext): QuietRole { + const role = this.sigChain.team.roles(roleName) + if (!role) { + throw new Error(`No role found with name ${roleName}`) + } + + return this.roleToQuietRole(role, context) + } + + public getAllRoles(context: LocalUserContext, haveAccessOnly: boolean = false): QuietRole[] { + const allRoles = this.sigChain.team.roles().map(role => this.roleToQuietRole(role, context)) + if (haveAccessOnly) { + return allRoles.filter((role: QuietRole) => role.hasRole === true) + } + + return allRoles + } + + public memberHasRole(memberId: string, roleName: string): boolean { + return this.sigChain.team.memberHasRole(memberId, roleName) + } + + public amIMemberOfRole(context: LocalUserContext, roleName: string): boolean { + return this.memberHasRole(context.user.userId, roleName) + } + + public getMembersForRole(roleName: string): Member[] { + return this.sigChain.team.membersInRole(roleName) + } + + private roleToQuietRole(role: Role, context: LocalUserContext): QuietRole { + const members = this.sigChain.roles.getMembersForRole(role.roleName) + const hasRole = this.sigChain.roles.amIMemberOfRole(context, role.roleName) + return { + ...role, + members, + hasRole, + } + } +} + +export { RoleService } diff --git a/packages/backend/src/nest/auth/services/roles/roles.ts b/packages/backend/src/nest/auth/services/roles/roles.ts new file mode 100644 index 0000000000..d2e19ee32e --- /dev/null +++ b/packages/backend/src/nest/auth/services/roles/roles.ts @@ -0,0 +1,31 @@ +import { Member, Role } from '@localfirst/auth' + +export enum RoleName { + ADMIN = 'admin', + MEMBER = 'member', +} + +export type RoleMemberInfo = { + id: string + name: string +} + +export type BaseQuietRole = Role & { + hasRole?: boolean +} + +export type QuietRole = BaseQuietRole & { + members: Member[] +} + +export type TruncatedQuietRole = BaseQuietRole & { + members: RoleMemberInfo[] +} + +export type BaseChannel = { + channelName: string +} + +export type Channel = QuietRole & BaseChannel + +export type TruncatedChannel = TruncatedQuietRole & BaseChannel diff --git a/packages/backend/src/nest/auth/sigchain.service.module.ts b/packages/backend/src/nest/auth/sigchain.service.module.ts new file mode 100644 index 0000000000..7ed9cc0315 --- /dev/null +++ b/packages/backend/src/nest/auth/sigchain.service.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common' +import { SigChainService } from './sigchain.service' +import { LocalDbModule } from '../local-db/local-db.module' + +@Module({ + providers: [SigChainService], + exports: [SigChainService], + imports: [LocalDbModule], +}) +export class SigChainModule {} diff --git a/packages/backend/src/nest/auth/sigchain.service.spec.ts b/packages/backend/src/nest/auth/sigchain.service.spec.ts new file mode 100644 index 0000000000..be44511164 --- /dev/null +++ b/packages/backend/src/nest/auth/sigchain.service.spec.ts @@ -0,0 +1,93 @@ +import { jest } from '@jest/globals' +import { Test, TestingModule } from '@nestjs/testing' +import { SigChainService } from './sigchain.service' +import { createLogger } from '../common/logger' +import { LocalDbService } from '../local-db/local-db.service' +import { LocalDbModule } from '../local-db/local-db.module' +import { TestModule } from '../common/test.module' +import { SigChainModule } from './sigchain.service.module' + +const logger = createLogger('auth:sigchainManager.spec') + +describe('SigChainManager', () => { + let module: TestingModule + let sigChainManager: SigChainService + let localDbService: LocalDbService + + beforeAll(async () => { + module = await Test.createTestingModule({ + imports: [TestModule, SigChainModule, LocalDbModule], + }).compile() + sigChainManager = await module.resolve(SigChainService) + localDbService = await module.resolve(LocalDbService) + }) + + beforeEach(async () => { + if (localDbService.getStatus() === 'closed') { + await localDbService.open() + } + }) + + afterAll(async () => { + await localDbService.close() + await module.close() + }) + + it('should throw an error when trying to get an active chain without setting one', async () => { + expect(() => sigChainManager.getActiveChain()).toThrowError() + }) + it('should throw an error when trying to set an active chain that does not exist', async () => { + expect(() => sigChainManager.setActiveChain('nonexistent')).toThrowError() + }) + it('should add a new chain and it not be active if not set to be', async () => { + const sigChain = await sigChainManager.createChain('test', 'user', false) + expect(() => sigChainManager.getActiveChain()).toThrowError() + sigChainManager.setActiveChain('test') + expect(sigChainManager.getActiveChain()).toBe(sigChain) + }) + it('should add a new chain and it be active if set to be', async () => { + const sigChain = await sigChainManager.createChain('test2', 'user2', true) + expect(sigChainManager.getActiveChain()).toBe(sigChain) + const prevSigChain = sigChainManager.getChain('test') + expect(prevSigChain).toBeDefined() + expect(prevSigChain).not.toBe(sigChain) + }) + it('should delete nonactive chain without changing active chain', async () => { + sigChainManager.setActiveChain('test2') + await sigChainManager.deleteChain('test', false) + expect(() => sigChainManager.getChain('test')).toThrowError() + expect(sigChainManager.getActiveChain()).toBeDefined() + }) + it('should delete active chain and set active chain to undefined', async () => { + await sigChainManager.deleteChain('test2', false) + expect(sigChainManager.getActiveChain).toThrowError() + }) + it('should save and load sigchain using nestjs service', async () => { + const TEAM_NAME = 'test3' + const sigChain = await sigChainManager.createChain(TEAM_NAME, 'user', true) + await sigChainManager.saveChain(TEAM_NAME) + await sigChainManager.deleteChain(TEAM_NAME, false) + const loadedSigChain = await sigChainManager.loadChain(TEAM_NAME, true) + expect(loadedSigChain).toBeDefined() + expect(sigChainManager.getActiveChain()).toBe(loadedSigChain) + }) + it('should delete sigchains from disk', async () => { + await sigChainManager.deleteChain('test3', true) + expect(() => sigChainManager.getChain('test3')).toThrowError() + await expect(sigChainManager.loadChain('test3', true)).rejects.toThrowError() + }) + it('should not allow duplicate chains to be added', async () => { + await sigChainManager.createChain('test4', 'user4', false) + await expect(sigChainManager.createChain('test4', 'user4', false)).rejects.toThrowError() + }) + it('should handle concurrent chain operations correctly', async () => { + const TEAM_NAME1 = 'test6' + const TEAM_NAME2 = 'test7' + await Promise.all([ + sigChainManager.createChain(TEAM_NAME1, 'user1', true), + sigChainManager.createChain(TEAM_NAME2, 'user2', false), + ]) + expect(sigChainManager.getChain(TEAM_NAME1)).toBeDefined() + expect(sigChainManager.getChain(TEAM_NAME2)).toBeDefined() + }) +}) diff --git a/packages/backend/src/nest/auth/sigchain.service.ts b/packages/backend/src/nest/auth/sigchain.service.ts new file mode 100644 index 0000000000..e93c6bd1b6 --- /dev/null +++ b/packages/backend/src/nest/auth/sigchain.service.ts @@ -0,0 +1,158 @@ +import { Injectable, OnModuleInit } from '@nestjs/common' +import { SigChain } from './sigchain' +import { Keyring, LocalUserContext } from '3rd-party/auth/packages/auth/dist' +import { LocalDbService } from '../local-db/local-db.service' +import { createLogger } from '../common/logger' + +@Injectable() +export class SigChainService implements OnModuleInit { + public activeChainTeamName: string | undefined + private readonly logger = createLogger(SigChainService.name) + private chains: Map = new Map() + private static _instance: SigChainService | undefined + + constructor(private readonly localDbService: LocalDbService) {} + + onModuleInit() { + if (SigChainService._instance) { + throw new Error('SigChainManagerService already initialized!') + } + SigChainService._instance = this + } + + getActiveChain(): SigChain { + if (!this.activeChainTeamName) { + throw new Error('No active chain found!') + } + return this.getChain(this.activeChainTeamName) + } + + /** + * Gets a chain by team name + * @param teamName Name of the team to get the chain for + * @returns The chain for the team + * @throws Error if the chain doesn't exist + */ + getChain(teamName: string): SigChain { + if (!this.chains.has(teamName)) { + throw new Error(`No chain found for team ${teamName}`) + } + return this.chains.get(teamName)! + } + + static get instance(): SigChainService { + if (!SigChainService._instance) { + throw new Error("SigChainManagerService hasn't been initialized yet! Run init() before accessing") + } + return SigChainService._instance + } + + setActiveChain(teamName: string): void { + if (!this.chains.has(teamName)) { + throw new Error(`No chain found for team ${teamName}, can't set to active!`) + } + this.activeChainTeamName = teamName + } + + /** + * Adds a chain to the service + * @param chain SigChain to add + * @param setActive Whether to set the chain as active + * @returns Whether the chain was set as active + */ + addChain(chain: SigChain, setActive: boolean): boolean { + if (this.chains.has(chain.team.teamName)) { + throw new Error(`Chain for team ${chain.team.teamName} already exists`) + } + this.chains.set(chain.team.teamName, chain) + if (setActive) { + this.setActiveChain(chain.team.teamName) + return true + } + return false + } + + /** + * Deletes a chain from the service + * @param teamName Name of the team to delete + * @param fromDisk Whether to delete the chain from disk as well + */ + async deleteChain(teamName: string, fromDisk: boolean): Promise { + if (fromDisk) { + this.localDbService.deleteSigChain(teamName) + } + this.chains.delete(teamName) + if (this.activeChainTeamName === teamName) { + this.activeChainTeamName = undefined + } + } + + /** + * Creates a new chain and adds it to the service + * @param teamName Name of the team to create + * @param username Name of the user to create + * @param setActive Whether to set the chain as active + * @returns The created chain + */ + async createChain(teamName: string, username: string, setActive: boolean): Promise { + if (this.chains.has(teamName)) { + throw new Error(`Chain for team ${teamName} already exists`) + } + const sigChain = SigChain.create(teamName, username) + this.addChain(sigChain, setActive) + return sigChain + } + + /** + * Deserializes a chain and adds it to the service + * @param serializedTeam Serialized chain to deserialize + * @param context User context to use for the chain + * @param teamKeyRing Keyring to use for the chain + * @param setActive Whether to set the chain as active + * @returns The SigChain instance created from the serialized chain + */ + private async deserialize( + serializedTeam: Uint8Array, + context: LocalUserContext, + teamKeyRing: Keyring, + setActive: boolean + ): Promise { + this.logger.info('Deserializing chain') + const sigChain = SigChain.load(serializedTeam, context, teamKeyRing) + this.addChain(sigChain, setActive) + return sigChain + } + + /* LevelDB methods */ + + /** + * Loads a chain from disk and adds it to the service + * @param teamName Name of the team to load + * @param setActive Whether to set the chain as active + * @returns The SigChain instance loaded from disk + * @throws Error if the chain doesn't exist + */ + async loadChain(teamName: string, setActive: boolean): Promise { + if (this.localDbService.getStatus() !== 'open') { + this.localDbService.open() + } + this.logger.info(`Loading chain for team ${teamName}`) + const chain = await this.localDbService.getSigChain(teamName) + if (!chain) { + throw new Error(`Chain for team ${teamName} not found`) + } + return await this.deserialize(chain.serializedTeam, chain.context, chain.teamKeyRing, setActive) + } + + /** + * Saves a chain to disk + * @param teamName Name of the team to save + */ + async saveChain(teamName: string): Promise { + if (this.localDbService.getStatus() !== 'open') { + this.localDbService.open() + } + const chain = await this.getChain(teamName) + await this.localDbService.setSigChain(chain) + } +} diff --git a/packages/backend/src/nest/auth/sigchain.spec.ts b/packages/backend/src/nest/auth/sigchain.spec.ts new file mode 100644 index 0000000000..2449c8de49 --- /dev/null +++ b/packages/backend/src/nest/auth/sigchain.spec.ts @@ -0,0 +1,36 @@ +import { jest } from '@jest/globals' +import { SigChain } from './sigchain' +import { SigChainService } from './sigchain.service' +import { createLogger } from '../common/logger' +import { LocalUserContext } from '3rd-party/auth/packages/auth/dist' +import exp from 'constants' +import { RoleName } from './services/roles/roles' +import { UserService } from './services/members/user.service' + +const logger = createLogger('auth:sigchainManager.spec') + +describe('SigChain', () => { + let sigChain: SigChain + let sigChain2: SigChain + + it('should initialize a new sigchain and be admin', () => { + sigChain = SigChain.create('test', 'user') + expect(sigChain).toBeDefined() + expect(sigChain.context).toBeDefined() + expect(sigChain.team.teamName).toBe('test') + expect(sigChain.context.user.userName).toBe('user') + expect(sigChain.roles.amIMemberOfRole(sigChain.context, RoleName.ADMIN)).toBe(true) + expect(sigChain.roles.amIMemberOfRole(sigChain.context, RoleName.MEMBER)).toBe(true) + }) + it('admin should not have a role that does not exist', () => { + expect(sigChain.roles.amIMemberOfRole(sigChain.context, 'nonexistent')).toBe(false) + }) + it('should serialize the sigchain and load it', () => { + const serializedChain = sigChain.save() + sigChain2 = SigChain.load(serializedChain, sigChain.context, sigChain.team.teamKeyring()) + expect(sigChain2).toBeDefined() + expect(sigChain2.team.teamName).toBe('test') + expect(sigChain2.roles.amIMemberOfRole(sigChain2.context, RoleName.ADMIN)).toBe(true) + expect(sigChain2.roles.amIMemberOfRole(sigChain2.context, RoleName.MEMBER)).toBe(true) + }) +}) diff --git a/packages/backend/src/nest/auth/sigchain.ts b/packages/backend/src/nest/auth/sigchain.ts new file mode 100644 index 0000000000..f21c88069d --- /dev/null +++ b/packages/backend/src/nest/auth/sigchain.ts @@ -0,0 +1,137 @@ +/** + * Handles generating the chain and aggregating all chain operations + */ + +import * as auth from '@localfirst/auth' +import { UserService } from './services/members/user.service' +import { RoleService } from './services/roles/role.service' +import { ChannelService } from './services/roles/channel.service' +import { DeviceService } from './services/members/device.service' +import { InviteService } from './services/invites/invite.service' +import { CryptoService } from './services/crypto/crypto.service' +import { RoleName } from './services/roles/roles' +import { createLogger } from '../common/logger' + +const logger = createLogger('auth:sigchain') + +class SigChain { + private _context: auth.LocalUserContext | null = null + private _team: auth.Team + private _users: UserService | null = null + private _devices: DeviceService | null = null + private _roles: RoleService | null = null + private _channels: ChannelService | null = null + private _invites: InviteService | null = null + private _crypto: CryptoService | null = null + + private constructor(team: auth.Team, context: auth.LocalUserContext) { + this._team = team + this._context = context + } + + /** + * Create a brand new SigChain with a given name and also generate the initial user with a given name + * + * @param teamName Name of the team we are creating + * @param username Username of the initial user we are generating + * @returns LoadedSigChain instance with the new SigChain and user context + */ + public static create(teamName: string, username: string): SigChain { + const context = UserService.create(username) + const team: auth.Team = this.lfa.createTeam(teamName, context) + const sigChain = this.init(team, context) + + // sigChain.roles.createWithMembers(RoleName.ADMIN, [context.user.userId]) + sigChain.roles.createWithMembers(RoleName.MEMBER, [context.user.userId]) + + return sigChain + } + + public static createFromTeam(team: auth.Team, context: auth.LocalUserContext): SigChain { + const sigChain = this.init(team, context) + return sigChain + } + + public static load(serializedTeam: Uint8Array, context: auth.LocalUserContext, teamKeyRing: auth.Keyring): SigChain { + const team: auth.Team = this.lfa.loadTeam(serializedTeam, context, teamKeyRing) + const sigChain = this.init(team, context) + + return sigChain + } + + // TODO: Is this the right signature for this method? + public static join(context: auth.LocalUserContext, serializedTeam: Uint8Array, teamKeyRing: auth.Keyring): SigChain { + const team: auth.Team = this.lfa.loadTeam(serializedTeam, context, teamKeyRing) + team.join(teamKeyRing) + + const sigChain = this.init(team, context) + + return sigChain + } + + private static init(team: auth.Team, context: auth.LocalUserContext): SigChain { + const sigChain = new SigChain(team, context) + sigChain.initServices() + + return sigChain + } + + private initServices() { + this._users = UserService.init(this) + this._devices = DeviceService.init(this) + this._roles = RoleService.init(this) + this._channels = ChannelService.init(this) + this._invites = InviteService.init(this) + this._crypto = CryptoService.init(this) + } + + public save(): Uint8Array { + return this.team.save() // this doesn't actually do anything but create the new state to save + } + + get context(): auth.LocalUserContext { + return this._context! + } + + set context(context: auth.LocalUserContext) { + this._context = context + } + + get team(): auth.Team { + return this._team + } + + get teamGraph(): auth.TeamGraph { + return this._team.graph + } + + get users(): UserService { + return this._users! + } + + get roles(): RoleService { + return this._roles! + } + + get channels(): ChannelService { + return this._channels! + } + + get devices(): DeviceService { + return this._devices! + } + + get invites(): InviteService { + return this._invites! + } + + get crypto(): CryptoService { + return this._crypto! + } + + static get lfa(): typeof auth { + return auth + } +} + +export { SigChain } diff --git a/packages/backend/src/nest/auth/types.ts b/packages/backend/src/nest/auth/types.ts new file mode 100644 index 0000000000..0e385f31e1 --- /dev/null +++ b/packages/backend/src/nest/auth/types.ts @@ -0,0 +1,13 @@ +import { Keyring, LocalUserContext } from '@localfirst/auth' + +export type SigChainSaveData = { + serializedTeam: string + context: LocalUserContext + teamKeyRing: Keyring +} + +export type SerializedSigChain = { + serializedTeam: Uint8Array + context: LocalUserContext + teamKeyRing: Keyring +} diff --git a/packages/backend/src/nest/common/utils.ts b/packages/backend/src/nest/common/utils.ts index 24b19f7960..3f11c53c77 100644 --- a/packages/backend/src/nest/common/utils.ts +++ b/packages/backend/src/nest/common/utils.ts @@ -124,7 +124,7 @@ export function generateRandomOnionAddress(length: number = 56): string { let randomString = '' const randomValues = new Uint32Array(length) - crypto.getRandomValues(randomValues) + crypto.webcrypto.getRandomValues(randomValues) for (let i = 0; i < length; i++) { randomString += charset[randomValues[i] % charsetLength] diff --git a/packages/backend/src/nest/connections-manager/connections-manager.module.ts b/packages/backend/src/nest/connections-manager/connections-manager.module.ts index 02d4d3a132..fb9afe04e9 100644 --- a/packages/backend/src/nest/connections-manager/connections-manager.module.ts +++ b/packages/backend/src/nest/connections-manager/connections-manager.module.ts @@ -7,6 +7,7 @@ import { TorModule } from '../tor/tor.module' import { ConnectionsManagerService } from './connections-manager.service' import { StorageServiceClientModule } from '../storageServiceClient/storageServiceClient.module' import { Libp2pModule } from '../libp2p/libp2p.module' +import { SigChainModule } from '../auth/sigchain.service.module' @Module({ imports: [ @@ -17,6 +18,7 @@ import { Libp2pModule } from '../libp2p/libp2p.module' SocketModule, LocalDbModule, StorageServiceClientModule, + SigChainModule, ], providers: [ConnectionsManagerService], exports: [ConnectionsManagerService], diff --git a/packages/backend/src/nest/connections-manager/connections-manager.service.spec.ts b/packages/backend/src/nest/connections-manager/connections-manager.service.spec.ts index 55110876a2..fe0c9097d9 100644 --- a/packages/backend/src/nest/connections-manager/connections-manager.service.spec.ts +++ b/packages/backend/src/nest/connections-manager/connections-manager.service.spec.ts @@ -1,7 +1,7 @@ import { jest } from '@jest/globals' import { Test, TestingModule } from '@nestjs/testing' -import { getFactory, prepareStore, type Store, type communities, type identity } from '@quiet/state-manager' +import { getFactory, identity, prepareStore, type Store, type communities } from '@quiet/state-manager' import { type Community, type Identity, type InitCommunityPayload } from '@quiet/types' import { type FactoryGirl } from 'factory-girl' import { TestModule } from '../common/test.module' @@ -16,6 +16,12 @@ import { SocketModule } from '../socket/socket.module' import { ConnectionsManagerModule } from './connections-manager.module' import { ConnectionsManagerService } from './connections-manager.service' import { createLibp2pAddress } from '@quiet/common' +import { SigChain } from '../auth/sigchain' +import { createLogger } from '../common/logger' +import { Logger } from '@nestjs/common' +import { SigChainService } from '../auth/sigchain.service' + +const logger = createLogger('connections-manager.service.spec') describe('ConnectionsManagerService', () => { let module: TestingModule @@ -27,6 +33,7 @@ describe('ConnectionsManagerService', () => { let community: Community let userIdentity: Identity let communityRootCa: string + let sigChainService: SigChainService beforeEach(async () => { jest.clearAllMocks() @@ -50,6 +57,12 @@ describe('ConnectionsManagerService', () => { connectionsManagerService = await module.resolve(ConnectionsManagerService) localDbService = await module.resolve(LocalDbService) + sigChainService = await module.resolve(SigChainService) + + // initialize sigchain on local db + sigChainService.createChain(community.name!, userIdentity.nickname, false) + sigChainService.saveChain(community.name!) + sigChainService.deleteChain(community.name!, false) quietDir = await module.resolve(QUIET_DIR) }) @@ -69,6 +82,7 @@ describe('ConnectionsManagerService', () => { }) it('launches community on init if its data exists in local db', async () => { + logger.info('launches community on init if its data exists in local db') const remotePeer = createLibp2pAddress( 'y7yczmugl2tekami7sbdz5pfaemvx7bahwthrdvcbzw5vex2crsr26qd', '12D3KooWKCWstmqi5gaQvipT7xVneVGfWV7HYpCbmUu626R92hXx' @@ -78,13 +92,16 @@ describe('ConnectionsManagerService', () => { // below const actualCommunity = { id: community.id, + name: community.name, peerList: [remotePeer], } + // await localDbService.setSigChain(sigChain) await localDbService.setCommunity(actualCommunity) await localDbService.setCurrentCommunityId(community.id) await localDbService.setIdentity(userIdentity) + logger.info('Closing all services') await connectionsManagerService.closeAllServices() const launchCommunitySpy = jest.spyOn(connectionsManagerService, 'launchCommunity').mockResolvedValue() @@ -94,10 +111,16 @@ describe('ConnectionsManagerService', () => { const localPeerAddress = createLibp2pAddress(userIdentity.hiddenService.onionAddress, userIdentity.peerId.id) const updatedLaunchCommunityPayload = { ...actualCommunity, peerList: [localPeerAddress, remotePeer] } - expect(launchCommunitySpy).toHaveBeenCalledWith(updatedLaunchCommunityPayload) + logger.info('updatedLaunchCommunityPayload', updatedLaunchCommunityPayload) + + // expect(launchCommunitySpy).toHaveBeenCalledWith(updatedLaunchCommunityPayload) + expect(launchCommunitySpy).toBeCalledWith(updatedLaunchCommunityPayload) + expect(sigChainService.getActiveChain()).toBeDefined() + expect(sigChainService.getActiveChain()?.team.teamName).toBe(community.name) }) it('does not launch community on init if its data does not exist in local db', async () => { + logger.info('does not launch community on init if its data does not exist in local db') await connectionsManagerService.closeAllServices() await connectionsManagerService.init() const launchCommunitySpy = jest.spyOn(connectionsManagerService, 'launchCommunity') diff --git a/packages/backend/src/nest/connections-manager/connections-manager.service.tor.spec.ts b/packages/backend/src/nest/connections-manager/connections-manager.service.tor.spec.ts index 8e6710cd6e..18a23701a7 100644 --- a/packages/backend/src/nest/connections-manager/connections-manager.service.tor.spec.ts +++ b/packages/backend/src/nest/connections-manager/connections-manager.service.tor.spec.ts @@ -198,10 +198,13 @@ describe('Connections manager', () => { localDbService.setIdentity(userIdentity) expect(connectionsManagerService.communityState).toBe(undefined) - // community will fail to launch from storage on init because factory community id - // will not match the one in the storage set in beforeEach + + localDbService.setCommunity({ ...community, peerList: peerList }) + localDbService.setCurrentCommunityId(community.id) + logger.info('Launching community', community.id, 'with peer list', peerList) await connectionsManagerService.init() - await connectionsManagerService.launchCommunity({ ...community, peerList: peerList }) + await sleep(5000) + expect(connectionsManagerService.communityState).toBe(ServiceState.LAUNCHED) await waitForExpect(async () => { @@ -312,10 +315,9 @@ describe('Connections manager', () => { localDbService.setIdentity(userIdentity) expect(connectionsManagerService.communityState).toBe(undefined) - // community will fail to launch from storage on init because factory community id - // will not match the one in the storage set in beforeEach + localDbService.setCommunity({ ...community, peerList: peerList }) + localDbService.setCurrentCommunityId(community.id) await connectionsManagerService.init() - await connectionsManagerService.launchCommunity({ ...community, peerList: peerList }) await waitForExpect(async () => { expect(connectionsManagerService.libp2pService.dialedPeers.size).toBe(MANY_PEERS_COUNT) diff --git a/packages/backend/src/nest/connections-manager/connections-manager.service.ts b/packages/backend/src/nest/connections-manager/connections-manager.service.ts index 267e55e55c..e228ad3f9a 100644 --- a/packages/backend/src/nest/connections-manager/connections-manager.service.ts +++ b/packages/backend/src/nest/connections-manager/connections-manager.service.ts @@ -78,6 +78,7 @@ import { DateTime } from 'luxon' import { createLogger } from '../common/logger' import { createFromJSON } from '@libp2p/peer-id-factory' import { PeerId } from '@libp2p/interface' +import { SigChainService } from '../auth/sigchain.service' @Injectable() export class ConnectionsManagerService extends EventEmitter implements OnModuleInit { @@ -99,7 +100,8 @@ export class ConnectionsManagerService extends EventEmitter implements OnModuleI private readonly storageServerProxyService: StorageServiceClient, private readonly localDbService: LocalDbService, private readonly storageService: StorageService, - private readonly tor: Tor + private readonly tor: Tor, + private readonly sigChainService: SigChainService ) { super() } @@ -220,7 +222,7 @@ export class ConnectionsManagerService extends EventEmitter implements OnModuleI public async launchCommunityFromStorage() { this.logger.info('Launching community from storage') - const community = await this.localDbService.getCurrentCommunity() + const community: Community | undefined = await this.localDbService.getCurrentCommunity() if (!community) { this.logger.info('No community found in storage') return @@ -232,6 +234,14 @@ export class ConnectionsManagerService extends EventEmitter implements OnModuleI return } + if (community.name) { + try { + await this.sigChainService.loadChain(community.name, true) + } catch (e) { + this.logger.warn('Failed to load sigchain', e) + } + } + const sortedPeers = await this.localDbService.getSortedPeers(community.peerList ?? []) this.logger.info('launchCommunityFromStorage - sorted peers', sortedPeers) if (sortedPeers.length > 0) { @@ -239,6 +249,7 @@ export class ConnectionsManagerService extends EventEmitter implements OnModuleI } await this.localDbService.setCommunity(community) + this.logger.info('Launching community from storage with peers', community.peerList) await this.launchCommunity(community) } @@ -246,6 +257,14 @@ export class ConnectionsManagerService extends EventEmitter implements OnModuleI await this.socketService.close() } + public async saveActiveChain() { + try { + await this.sigChainService.saveChain(this.sigChainService.activeChainTeamName!) + } catch (e) { + this.logger.info('Failed to save active chain', e) + } + } + public async pause() { this.logger.info('Pausing!') await this.closeSocket() @@ -284,6 +303,7 @@ export class ConnectionsManagerService extends EventEmitter implements OnModuleI public async closeAllServices(options: { saveTor: boolean } = { saveTor: false }) { this.logger.info('Closing services') + await this.saveActiveChain() await this.closeSocket() @@ -576,26 +596,34 @@ export class ConnectionsManagerService extends EventEmitter implements OnModuleI if (identity.userCsr?.userCsr) { await this.storageService.saveCSR({ csr: identity.userCsr.userCsr }) } - return community - } - public async downloadCommunityData(inviteData: InvitationDataV2) { - this.logger.info('Downloading invite data', inviteData) - this.storageServerProxyService.setServerAddress(inviteData.serverAddress) - let downloadedData: ServerStoredCommunityMetadata - try { - downloadedData = await this.storageServerProxyService.downloadData(inviteData.cid) - } catch (e) { - this.logger.error(`Downloading community data failed`, e) - return - } - return { - psk: downloadedData.psk, - peers: downloadedData.peerList, - ownerOrbitDbIdentity: downloadedData.ownerOrbitDbIdentity, + // create sigchain + if (!community.name) { + this.logger.error('Community name is required to create sigchain') + return community } + this.sigChainService.createChain(community.name, identity.nickname, true) + return community } + // TODO: add back when QSS is implemented + // public async downloadCommunityData(inviteData: InvitationDataV2) { + // this.logger.info('Downloading invite data', inviteData) + // this.storageServerProxyService.setServerAddress(inviteData.serverAddress) + // let downloadedData: ServerStoredCommunityMetadata + // try { + // downloadedData = await this.storageServerProxyService.downloadData(inviteData.cid) + // } catch (e) { + // this.logger.error(`Downloading community data failed`, e) + // return + // } + // return { + // psk: downloadedData.psk, + // peers: downloadedData.peerList, + // ownerOrbitDbIdentity: downloadedData.ownerOrbitDbIdentity, + // } + // } + public async joinCommunity(payload: InitCommunityPayload): Promise { this.logger.info('Joining community: peers:', payload.peers) const identity = await this.storageService.getIdentity(payload.id) @@ -609,29 +637,30 @@ export class ConnectionsManagerService extends EventEmitter implements OnModuleI return } - let metadata = { + const metadata = { psk: payload.psk, peers: payload.peers, ownerOrbitDbIdentity: payload.ownerOrbitDbIdentity, } const inviteData = payload.inviteData - if (inviteData) { - this.logger.info(`Joining community: inviteData version: ${inviteData.version}`) - switch (inviteData.version) { - case InvitationDataVersion.v2: - const downloadedData = await this.downloadCommunityData(inviteData) - if (!downloadedData) { - emitError(this.serverIoProvider.io, { - type: SocketActionTypes.LAUNCH_COMMUNITY, - message: ErrorMessages.STORAGE_SERVER_CONNECTION_FAILED, - }) - return - } - metadata = downloadedData - break - } - } + // TODO: add back when QSS is implemented + // if (inviteData) { + // this.logger.info(`Joining community: inviteData version: ${inviteData.version}`) + // switch (inviteData.version) { + // case InvitationDataVersion.v2: + // const downloadedData = await this.downloadCommunityData(inviteData) + // if (!downloadedData) { + // emitError(this.serverIoProvider.io, { + // type: SocketActionTypes.LAUNCH_COMMUNITY, + // message: ErrorMessages.STORAGE_SERVER_CONNECTION_FAILED, + // }) + // return + // } + // metadata = downloadedData + // break + // } + // } if (!metadata.peers || metadata.peers.length === 0) { this.logger.error('Joining community: Peers required') @@ -668,6 +697,7 @@ export class ConnectionsManagerService extends EventEmitter implements OnModuleI inviteData, } + // TODO: Add initialization of sigchain from invite await this.localDbService.setCommunity(community) await this.localDbService.setCurrentCommunityId(community.id) diff --git a/packages/backend/src/nest/local-db/local-db.service.ts b/packages/backend/src/nest/local-db/local-db.service.ts index 27b876e93e..8a9b0da5b8 100644 --- a/packages/backend/src/nest/local-db/local-db.service.ts +++ b/packages/backend/src/nest/local-db/local-db.service.ts @@ -1,3 +1,4 @@ +import { Buffer } from 'buffer' import { Inject, Injectable } from '@nestjs/common' import { Level } from 'level' import { type Community, type NetworkInfo, NetworkStats, Identity, IdentityUpdatePayload } from '@quiet/types' @@ -5,6 +6,8 @@ import { createLibp2pAddress, filterAndSortPeers } from '@quiet/common' import { LEVEL_DB } from '../const' import { LocalDBKeys, LocalDbStatus } from './local-db.types' import { createLogger } from '../common/logger' +import { SerializedSigChain, SigChainSaveData } from '../auth/types' +import { SigChain } from '../auth/sigchain' @Injectable() export class LocalDbService { @@ -161,4 +164,39 @@ export class LocalDbService { public async getIdentities(): Promise> { return await this.get(LocalDBKeys.IDENTITIES) } + + public async setSigChain(sigChain: SigChain) { + const teamName = sigChain.team.teamName + const key = `${LocalDBKeys.SIGCHAINS}${teamName}` + const serializedSigChain: SigChainSaveData = { + serializedTeam: Buffer.from(sigChain.save()).toString('base64'), + context: sigChain.context, + teamKeyRing: sigChain.team.teamKeyring(), + } + this.logger.info('Saving sigchain', teamName) + await this.put(key, serializedSigChain) + } + + public async getSigChain(teamName: string): Promise { + const key = `${LocalDBKeys.SIGCHAINS}${teamName}` + this.logger.info('Getting sigchain', teamName, key) + const sigChainBlob = await this.get(key) + if (sigChainBlob) { + // convert serializedTeam from base64 to buffer to Uint8Array + const serializedTeamBuffer = Buffer.from(sigChainBlob.serializedTeam, 'base64') + return { + serializedTeam: new Uint8Array(serializedTeamBuffer), + context: sigChainBlob.context, + teamKeyRing: sigChainBlob.teamKeyRing, + } as SerializedSigChain + } else { + this.logger.error('Sigchain not found', teamName) + return undefined + } + } + + public async deleteSigChain(teamName: string) { + const key = `${LocalDBKeys.SIGCHAINS}${teamName}` + await this.delete(key) + } } diff --git a/packages/backend/src/nest/local-db/local-db.types.ts b/packages/backend/src/nest/local-db/local-db.types.ts index 3891a82966..819860fb15 100644 --- a/packages/backend/src/nest/local-db/local-db.types.ts +++ b/packages/backend/src/nest/local-db/local-db.types.ts @@ -17,5 +17,9 @@ export enum LocalDBKeys { // TODO: Deprecate this soon (and delete the data from LevelDB). This data // exists in the Community object. OWNER_ORBIT_DB_IDENTITY = 'ownerOrbitDbIdentity', + + SIGCHAINS = 'sigchains:', + USER_CONTEXTS = 'userContexts', + KEYRINGS = 'keyrings', } export type LocalDbStatus = 'opening' | 'open' | 'closing' | 'closed' diff --git a/packages/backend/tsconfig.build.json b/packages/backend/tsconfig.build.json index e8c62532d1..4a72254fc4 100644 --- a/packages/backend/tsconfig.build.json +++ b/packages/backend/tsconfig.build.json @@ -15,7 +15,9 @@ "outDir": "./lib", "typeRoots": [ "../@types", - "./node_modules/@types" + "./node_modules/@types", + "./3rd-party/auth/packages/auth/src", + "./3rd-party/auth/packages/crdx/src", ], }, "include": [ diff --git a/packages/backend/tsconfig.json b/packages/backend/tsconfig.json index 236be164d1..25bbdf8246 100644 --- a/packages/backend/tsconfig.json +++ b/packages/backend/tsconfig.json @@ -15,7 +15,9 @@ "moduleResolution": "node", "typeRoots": [ "../@types", - "./node_modules/@types" + "./node_modules/@types", + "../../3rd-party/auth/packages/auth/src", + "../../3rd-party/auth/packages/crdx/src", ], }, "include": [ diff --git a/packages/common/src/index.ts b/packages/common/src/index.ts index 71099f52b4..625bb61803 100644 --- a/packages/common/src/index.ts +++ b/packages/common/src/index.ts @@ -1,4 +1,5 @@ -export * from './invitationCode' +export * from './invitationLink/invitationLink' +export * from './invitationLink/invitationLink.const' export * from './const' export * from './capitalize' export * from './process' diff --git a/packages/common/src/invitationCode.test.ts b/packages/common/src/invitationCode.test.ts deleted file mode 100644 index 96c69729e6..0000000000 --- a/packages/common/src/invitationCode.test.ts +++ /dev/null @@ -1,172 +0,0 @@ -import { InvitationDataV1, InvitationDataVersion, InvitationPair } from '@quiet/types' -import { - argvInvitationCode, - composeInvitationDeepUrl, - composeInvitationShareUrl, - parseInvitationLinkDeepUrl, - PSK_PARAM_KEY, - OWNER_ORBIT_DB_IDENTITY_PARAM_KEY, - p2pAddressesToPairs, - CID_PARAM_KEY, - TOKEN_PARAM_KEY, - SERVER_ADDRESS_PARAM_KEY, - INVITER_ADDRESS_PARAM_KEY, - DEEP_URL_SCHEME_WITH_SEPARATOR, -} from './invitationCode' -import { QUIET_JOIN_PAGE } from './const' -import { validInvitationDatav1, validInvitationDatav2 } from './tests' -import { createLibp2pAddress } from './libp2p' - -describe(`Invitation code helper ${InvitationDataVersion.v1}`, () => { - const address = 'gloao6h5plwjy4tdlze24zzgcxll6upq2ex2fmu2ohhyu4gtys4nrjad' - const peerId = '12D3KooWSYQf8zzr5rYnUdLxYyLzHruQHPaMssja1ADifGAcN4zF' - const data: InvitationDataV1 = { - ...validInvitationDatav1[0], - pairs: [...validInvitationDatav1[0].pairs, { peerId: peerId, onionAddress: address }], - } - const urlParams = [ - [data.pairs[0].peerId, data.pairs[0].onionAddress], - [data.pairs[1].peerId, data.pairs[1].onionAddress], - [PSK_PARAM_KEY, data.psk], - [OWNER_ORBIT_DB_IDENTITY_PARAM_KEY, data.ownerOrbitDbIdentity], - ] - - it('retrieves invitation code from argv', () => { - const result = argvInvitationCode(['something', 'quiet:/invalid', 'zbay://invalid', composeInvitationDeepUrl(data)]) - expect(result).toEqual(data) - }) - - it('returns null if argv do not contain any url with proper scheme', () => { - const result = argvInvitationCode(['something', 'quiet:/invalid', 'zbay://invalid']) - expect(result).toBeNull() - }) - - it('throws error if argv contains invalid invitation url', () => { - expect(() => { - argvInvitationCode(['something', 'quiet:/invalid', 'quiet://?param=invalid']) - }).toThrow() - }) - - it('composes proper invitation deep url', () => { - const url = new URL(DEEP_URL_SCHEME_WITH_SEPARATOR) - urlParams.forEach(([key, value]) => url.searchParams.append(key, value)) - expect(composeInvitationDeepUrl(data)).toEqual(url.href) - }) - - it('creates invitation share url based on invitation data', () => { - const url = new URL(QUIET_JOIN_PAGE) - urlParams.forEach(([key, value]) => url.searchParams.append(key, value)) - expect(composeInvitationShareUrl(data)).toEqual(url.href.replace('?', '#')) - }) - - it('converts list of p2p addresses to invitation pairs', () => { - const pair: InvitationPair = { - peerId: '12D3KooWSYQf8zzr5rYnUdLxYyLzHruQHPaMssja1ADifGAcN4zF', - onionAddress: 'gloao6h5plwjy4tdlze24zzgcxll6upq2ex2fmu2ohhyu4gtys4nrjad', - } - const peerList = [ - createLibp2pAddress(pair.onionAddress, pair.peerId), - 'invalidAddress', - createLibp2pAddress('somethingElse.onion', '12D3KooWSYQf8zzr5rYnUdLxYyLzHruQHPaMssja1ADifGAcN3qY'), - ] - expect(p2pAddressesToPairs(peerList)).toEqual([pair]) - }) - - it('retrieves invitation codes from deep url', () => { - const url = new URL(DEEP_URL_SCHEME_WITH_SEPARATOR) - urlParams.forEach(([key, value]) => url.searchParams.append(key, value)) - - const codes = parseInvitationLinkDeepUrl(url.href) - expect(codes).toEqual({ - version: InvitationDataVersion.v1, - ...data, - }) - }) - - it.each([ - [PSK_PARAM_KEY, '12345'], - [PSK_PARAM_KEY, 'a2FzemE='], - [PSK_PARAM_KEY, 'a2FycGllIHcgZ2FsYXJlY2llIGVjaWUgcGVjaWUgYWxlIGkgdGFrIHpqZWNpZQ=='], - ])('parsing deep url throws error if data is invalid: %s=%s', (paramKey: string, paramValue: string) => { - const url = new URL(DEEP_URL_SCHEME_WITH_SEPARATOR) - urlParams.forEach(([key, value]) => url.searchParams.append(key, value)) - - // Replace valid param value with invalid one - url.searchParams.set(paramKey, paramValue) - - expect(() => { - parseInvitationLinkDeepUrl(url.href) - }).toThrow() - }) - - it('retrieves invitation codes from deep url with partly invalid addresses', () => { - const urlParamsWithInvalidAddress = [ - [data.pairs[0].peerId, data.pairs[0].onionAddress], - [data.pairs[1].peerId, data.pairs[1].onionAddress], - ['12D3KooWSYQf8zzr5rYnUdLxYyLzHruQHPaMssja1ADifGAcN4zF', 'y7yczmugl2tekami7sbdz5pfaemvx7bahwthrdv'], - [PSK_PARAM_KEY, data.psk], - [OWNER_ORBIT_DB_IDENTITY_PARAM_KEY, data.ownerOrbitDbIdentity], - ] - - const url = new URL(DEEP_URL_SCHEME_WITH_SEPARATOR) - urlParamsWithInvalidAddress.forEach(([key, value]) => url.searchParams.append(key, value)) - - const parsed = parseInvitationLinkDeepUrl(url.href) - expect(parsed).toEqual({ - version: InvitationDataVersion.v1, - ...data, - }) - }) -}) - -describe(`Invitation code helper ${InvitationDataVersion.v2}`, () => { - const data = validInvitationDatav2[0] - const urlParams = [ - [CID_PARAM_KEY, data.cid], - [TOKEN_PARAM_KEY, data.token], - [SERVER_ADDRESS_PARAM_KEY, data.serverAddress], - [INVITER_ADDRESS_PARAM_KEY, data.inviterAddress], - ] - - it('creates invitation share url based on invitation data', () => { - const url = new URL(QUIET_JOIN_PAGE) - urlParams.forEach(([key, value]) => url.searchParams.append(key, value)) - expect(composeInvitationShareUrl(data)).toEqual(url.href.replace('?', '#')) - }) - - it('composes proper invitation deep url', () => { - const url = new URL(DEEP_URL_SCHEME_WITH_SEPARATOR) - urlParams.forEach(([key, value]) => url.searchParams.append(key, value)) - expect(composeInvitationDeepUrl(data)).toEqual(url.href) - }) - - it('retrieves invitation codes from deep url v2', () => { - const url = new URL(DEEP_URL_SCHEME_WITH_SEPARATOR) - urlParams.forEach(([key, value]) => url.searchParams.append(key, value)) - const codes = parseInvitationLinkDeepUrl(url.href) - expect(codes).toEqual({ - version: InvitationDataVersion.v2, - cid: data.cid, - token: data.token, - serverAddress: data.serverAddress, - inviterAddress: data.inviterAddress, - }) - }) - - it.each([ - // TODO: add check for invalid token - [CID_PARAM_KEY, 'sth'], - [SERVER_ADDRESS_PARAM_KEY, 'website.com'], - [INVITER_ADDRESS_PARAM_KEY, 'abcd'], - ])('parsing deep url throws error if data is invalid: %s=%s', (paramKey: string, paramValue: string) => { - const url = new URL(DEEP_URL_SCHEME_WITH_SEPARATOR) - urlParams.forEach(([key, value]) => url.searchParams.append(key, value)) - - // Replace valid param value with invalid one - url.searchParams.set(paramKey, paramValue) - - expect(() => { - parseInvitationLinkDeepUrl(url.href) - }).toThrow() - }) -}) diff --git a/packages/common/src/invitationCode.ts b/packages/common/src/invitationCode.ts deleted file mode 100644 index b093dbe320..0000000000 --- a/packages/common/src/invitationCode.ts +++ /dev/null @@ -1,298 +0,0 @@ -import { InvitationData, InvitationDataV1, InvitationDataV2, InvitationDataVersion, InvitationPair } from '@quiet/types' -import { QUIET_JOIN_PAGE } from './const' -import { createLibp2pAddress, isPSKcodeValid } from './libp2p' -// import { CID } from 'multiformats/cid' // Fixme: dependency issue -import { createLogger } from './logger' -const logger = createLogger('invite') - -// V1 invitation code format (p2p without relay) -export const PSK_PARAM_KEY = 'k' -export const OWNER_ORBIT_DB_IDENTITY_PARAM_KEY = 'o' - -// V2 invitation code format (relay support) -export const CID_PARAM_KEY = 'c' -export const TOKEN_PARAM_KEY = 't' -export const INVITER_ADDRESS_PARAM_KEY = 'i' -export const SERVER_ADDRESS_PARAM_KEY = 's' - -export const DEEP_URL_SCHEME_WITH_SEPARATOR = 'quiet://' -const DEEP_URL_SCHEME = 'quiet' -const ONION_ADDRESS_REGEX = /^[a-z0-9]{56}$/g -const PEER_ID_REGEX = /^[a-zA-Z0-9]{52}$/g - -interface ParseDeepUrlParams { - url: string - expectedProtocol?: string -} - -const parseCodeV2 = (url: string): InvitationDataV2 => { - /** - * c=&t=&s=&i= - */ - const params = new URL(url).searchParams - const requiredParams = [CID_PARAM_KEY, TOKEN_PARAM_KEY, SERVER_ADDRESS_PARAM_KEY, INVITER_ADDRESS_PARAM_KEY] - - const entries = validateUrlParams(params, requiredParams) - - return { - version: InvitationDataVersion.v2, - cid: entries[CID_PARAM_KEY], - token: entries[TOKEN_PARAM_KEY], - serverAddress: entries[SERVER_ADDRESS_PARAM_KEY], - inviterAddress: entries[INVITER_ADDRESS_PARAM_KEY], - } -} - -const parseCodeV1 = (url: string): InvitationDataV1 => { - /** - * =&=...&k=&o= - */ - const params = new URL(url).searchParams - const requiredParams = [PSK_PARAM_KEY, OWNER_ORBIT_DB_IDENTITY_PARAM_KEY] - - const entries = validateUrlParams(params, requiredParams) - - const codes: InvitationPair[] = [] - - params.forEach((onionAddress, peerId) => { - if (!peerDataValid({ peerId, onionAddress })) return - codes.push({ - peerId, - onionAddress, - }) - }) - - if (codes.length === 0) throw new Error(`No valid peer addresses found in invitation code '${url}'`) - - return { - version: InvitationDataVersion.v1, - pairs: codes, - psk: entries[PSK_PARAM_KEY], - ownerOrbitDbIdentity: entries[OWNER_ORBIT_DB_IDENTITY_PARAM_KEY], - } -} - -const parseDeepUrl = ({ url, expectedProtocol = `${DEEP_URL_SCHEME}:` }: ParseDeepUrlParams): InvitationData => { - let _url = url - let validUrl: URL | null = null - - if (!expectedProtocol) { - // Create a full url to be able to use the same URL parsing mechanism - expectedProtocol = `${DEEP_URL_SCHEME}:` - _url = `${DEEP_URL_SCHEME_WITH_SEPARATOR}?${url}` - } - - try { - validUrl = new URL(_url) - } catch (e) { - logger.error(`Could not retrieve invitation code from deep url '${url}'`, e) - throw e - } - if (!validUrl || validUrl.protocol !== expectedProtocol) { - logger.error(`Could not retrieve invitation code from deep url '${url}'`) - throw new Error(`Invalid url`) - } - - const params = validUrl.searchParams - - const psk = params.get(PSK_PARAM_KEY) - const cid = params.get(CID_PARAM_KEY) - if (!psk && !cid) throw new Error(`Invitation code does not match either v1 or v2 format '${url}'`) - - let data: InvitationData - if (psk) { - data = parseCodeV1(_url) - } else { - data = parseCodeV2(_url) - } - - if (!data) throw new Error(`Could not parse invitation code from deep url '${url}'`) - - logger.info(`Invitation data '${JSON.stringify(data)}' parsed`) - return data -} - -/** - * Extract invitation data from deep url. - * Valid format: quiet://?=&=&k= - */ -export const parseInvitationLinkDeepUrl = (url: string): InvitationData => { - return parseDeepUrl({ url }) -} - -/** - * @param code =&=&k= - */ -export const parseInvitationLink = (inviteLink: string): InvitationData => { - return parseDeepUrl({ url: inviteLink, expectedProtocol: '' }) -} - -export const p2pAddressesToPairs = (addresses: string[]): InvitationPair[] => { - /** - * @arg {string[]} addresses - List of peer's p2p addresses - */ - const pairs: InvitationPair[] = [] - for (const peerAddress of addresses) { - let peerId: string - let onionAddress: string - try { - peerId = peerAddress.split('/p2p/')[1] - } catch (e) { - logger.error(`Could not add peer address '${peerAddress}' to invitation url.`, e) - continue - } - try { - onionAddress = peerAddress.split('/tcp/')[0].split('/dns4/')[1] - } catch (e) { - logger.error(`Could not add peer address '${peerAddress}' to invitation url.`, e) - continue - } - - if (!peerId || !onionAddress) { - logger.error(`No peerId or address in ${peerAddress}`) - continue - } - const rawAddress = onionAddress.endsWith('.onion') ? onionAddress.split('.')[0] : onionAddress - if (!peerDataValid({ peerId, onionAddress: rawAddress })) continue - - pairs.push({ peerId: peerId, onionAddress: rawAddress }) - } - return pairs -} - -export const pairsToP2pAddresses = (pairs: InvitationPair[]): string[] => { - const addresses: string[] = [] - for (const pair of pairs) { - addresses.push(createLibp2pAddress(pair.onionAddress, pair.peerId)) - } - return addresses -} - -export const composeInvitationShareUrl = (data: InvitationData) => { - /** - * @returns {string} - Complete shareable invitation link, e.g. - * https://tryquiet.org/join/#=&=&k=&o= - */ - return composeInvitationUrl(`${QUIET_JOIN_PAGE}`, data).replace('?', '#') -} - -export const composeInvitationDeepUrl = (data: InvitationData): string => { - return composeInvitationUrl(`${DEEP_URL_SCHEME_WITH_SEPARATOR}`, data) -} - -const composeInvitationUrl = (baseUrl: string, data: InvitationDataV1 | InvitationDataV2): string => { - const url = new URL(baseUrl) - - if (!data.version) data.version = InvitationDataVersion.v1 - - switch (data.version) { - case InvitationDataVersion.v1: - for (const pair of data.pairs) { - url.searchParams.append(pair.peerId, pair.onionAddress) - } - url.searchParams.append(PSK_PARAM_KEY, data.psk) - url.searchParams.append(OWNER_ORBIT_DB_IDENTITY_PARAM_KEY, data.ownerOrbitDbIdentity) - break - case InvitationDataVersion.v2: - url.searchParams.append(CID_PARAM_KEY, data.cid) - url.searchParams.append(TOKEN_PARAM_KEY, data.token) - url.searchParams.append(SERVER_ADDRESS_PARAM_KEY, data.serverAddress) - url.searchParams.append(INVITER_ADDRESS_PARAM_KEY, data.inviterAddress) - break - } - return url.href -} - -/** - * Extract invitation codes from deep url if url is present in argv - */ -export const argvInvitationCode = (argv: string[]): InvitationData | null => { - let invitationData: InvitationData | null = null - for (const arg of argv) { - if (!arg.startsWith(DEEP_URL_SCHEME_WITH_SEPARATOR)) { - logger.warn('Not a deep url, not parsing', arg) - continue - } - logger.info('Parsing deep url', arg) - invitationData = parseInvitationLinkDeepUrl(arg) - switch (invitationData.version) { - case InvitationDataVersion.v1: - if (invitationData.pairs.length > 0) { - break - } else { - invitationData = null - } - } - } - return invitationData -} - -const peerDataValid = ({ peerId, onionAddress }: { peerId: string; onionAddress: string }): boolean => { - if (!peerId.match(PEER_ID_REGEX)) { - // TODO: test it more properly e.g with PeerId.createFromB58String(peerId.trim()) - logger.warn(`PeerId ${peerId} is not valid`) - return false - } - if (!onionAddress.trim().match(ONION_ADDRESS_REGEX)) { - logger.warn(`Onion address ${onionAddress} is not valid`) - return false - } - return true -} - -const validateUrlParams = (params: URLSearchParams, requiredParams: string[]) => { - const entries = Object.fromEntries(params) - - requiredParams.forEach(key => { - const value = params.get(key) - if (!value) { - throw new Error(`Missing key '${key}' in invitation code`) - } - entries[key] = decodeURIComponent(value) - if (!isParamValid(key, entries[key])) { - throw new Error(`Invalid value '${value}' for key '${key}' in invitation code`) - } - params.delete(key) - }) - return entries -} - -const isParamValid = (param: string, value: string) => { - logger.info(`Validating param ${param} with value ${value}`) - switch (param) { - case CID_PARAM_KEY: - // try { - // CID.parse(value) - // } catch (e) { - // logger.error(e.message) - // return false - // } - return Boolean(value.match(PEER_ID_REGEX)) - - case TOKEN_PARAM_KEY: - // TODO: validate token format - return true - - case SERVER_ADDRESS_PARAM_KEY: - try { - new URL(value) - } catch (e) { - logger.error(`Error while URL encoding ${value}`, e) - return false - } - return true - - case INVITER_ADDRESS_PARAM_KEY: - return Boolean(value.trim().match(ONION_ADDRESS_REGEX)) - - case PSK_PARAM_KEY: - return isPSKcodeValid(value) - - case OWNER_ORBIT_DB_IDENTITY_PARAM_KEY: - // TODO: validate orbit db identity format? - return true - - default: - return false - } -} diff --git a/packages/common/src/invitationLink/invitationLink.const.ts b/packages/common/src/invitationLink/invitationLink.const.ts new file mode 100644 index 0000000000..54639a46c9 --- /dev/null +++ b/packages/common/src/invitationLink/invitationLink.const.ts @@ -0,0 +1,20 @@ +// V1 invitation code format (p2p without relay) +export const PSK_PARAM_KEY = 'k' +export const OWNER_ORBIT_DB_IDENTITY_PARAM_KEY = 'o' +export const PEER_ADDRESS_KEY = 'p' + +// v2 invitation code format (v1 with LFA integration) +export const AUTH_DATA_KEY = 'a' +export const COMMUNITY_NAME_KEY = 'c' +export const INVITATION_SEED_KEY = 's' +export const AUTH_DATA_OBJECT_KEY = 'authData' + +// TODO: QSS integration +// V? invitation code format (oss support) +// export const CID_PARAM_KEY = 'c' +// export const TOKEN_PARAM_KEY = 't' +// export const INVITER_ADDRESS_PARAM_KEY = 'i' +// export const SERVER_ADDRESS_PARAM_KEY = 's' + +export const DEEP_URL_SCHEME_WITH_SEPARATOR = 'quiet://' +export const DEEP_URL_SCHEME = 'quiet' diff --git a/packages/common/src/invitationLink/invitationLink.test.ts b/packages/common/src/invitationLink/invitationLink.test.ts new file mode 100644 index 0000000000..90f5c6cbc6 --- /dev/null +++ b/packages/common/src/invitationLink/invitationLink.test.ts @@ -0,0 +1,354 @@ +import { InvitationDataV1, InvitationDataV2, InvitationDataVersion, InvitationPair } from '@quiet/types' +import { + argvInvitationLink, + composeInvitationDeepUrl, + composeInvitationShareUrl, + parseInvitationLinkDeepUrl, + p2pAddressesToPairs, + peerPairsToUrlParamString, +} from './invitationLink' +import { + PSK_PARAM_KEY, + OWNER_ORBIT_DB_IDENTITY_PARAM_KEY, + DEEP_URL_SCHEME_WITH_SEPARATOR, + AUTH_DATA_KEY, + PEER_ADDRESS_KEY, +} from './invitationLink.const' +import { QUIET_JOIN_PAGE } from '../const' +import { validInvitationDatav1, validInvitationDatav2 } from '../tests' +import { createLibp2pAddress } from '../libp2p' +import { encodeAuthData } from './invitationLink.validator' + +describe(`Invitation link helper ${InvitationDataVersion.v1}`, () => { + const address = 'gloao6h5plwjy4tdlze24zzgcxll6upq2ex2fmu2ohhyu4gtys4nrjad' + const peerId = 'QmZoiJNAvCffeEHBjk766nLuKVdkxkAT7wfFJDPPLsbKSE' + const data: InvitationDataV1 = { + ...validInvitationDatav1[0], + pairs: [...validInvitationDatav1[0].pairs, { peerId: peerId, onionAddress: address }], + } + const urlParams = [ + [PEER_ADDRESS_KEY, peerPairsToUrlParamString([data.pairs[0], data.pairs[1]])], + [PSK_PARAM_KEY, data.psk], + [OWNER_ORBIT_DB_IDENTITY_PARAM_KEY, data.ownerOrbitDbIdentity], + ] + + it('retrieves invitation link from argv', () => { + const result = argvInvitationLink(['something', 'quiet:/invalid', 'zbay://invalid', composeInvitationDeepUrl(data)]) + expect(result).toEqual(data) + }) + + it('returns null if argv do not contain any url with proper scheme', () => { + const result = argvInvitationLink(['something', 'quiet:/invalid', 'zbay://invalid']) + expect(result).toBeNull() + }) + + it('throws error if argv contains invalid invitation url', () => { + expect(() => { + argvInvitationLink(['something', 'quiet:/invalid', 'quiet://?param=invalid']) + }).toThrow() + }) + + it('composes proper invitation deep url', () => { + const url = new URL(DEEP_URL_SCHEME_WITH_SEPARATOR) + urlParams.forEach(([key, value]) => url.searchParams.append(key, value)) + expect(composeInvitationDeepUrl(data)).toEqual(url.href) + }) + + it('creates invitation share url based on invitation data', () => { + const url = new URL(QUIET_JOIN_PAGE) + urlParams.forEach(([key, value]) => url.searchParams.append(key, value)) + expect(composeInvitationShareUrl(data)).toEqual(url.href.replace('?', '#')) + }) + + it('converts list of p2p addresses to invitation pairs', () => { + const pair: InvitationPair = { + peerId: 'QmZoiJNAvCffeEHBjk766nLuKVdkxkAT7wfFJDPPLsbKSE', + onionAddress: 'gloao6h5plwjy4tdlze24zzgcxll6upq2ex2fmu2ohhyu4gtys4nrjad', + } + const peerList = [ + createLibp2pAddress(pair.onionAddress, pair.peerId), + 'invalidAddress', + createLibp2pAddress('somethingElse.onion', 'QmZoiJNAvCffeEHBjk766nLuKVdkxkAT7wfFJDPPLsbKSA'), + ] + expect(p2pAddressesToPairs(peerList)).toEqual([pair]) + }) + + it('retrieves invitation data from deep url', () => { + const url = new URL(DEEP_URL_SCHEME_WITH_SEPARATOR) + urlParams.forEach(([key, value]) => url.searchParams.append(key, value)) + + const parsed = parseInvitationLinkDeepUrl(url.href) + expect(parsed).toEqual({ + version: InvitationDataVersion.v1, + ...data, + }) + }) + + it.each([ + [PSK_PARAM_KEY, '12345'], + [PSK_PARAM_KEY, 'a2FzemE='], + [PSK_PARAM_KEY, 'a2FycGllIHcgZ2FsYXJlY2llIGVjaWUgcGVjaWUgYWxlIGkgdGFrIHpqZWNpZQ=='], + ])('parsing deep url throws error if data is invalid: %s=%s', (paramKey: string, paramValue: string) => { + const url = new URL(DEEP_URL_SCHEME_WITH_SEPARATOR) + urlParams.forEach(([key, value]) => url.searchParams.append(key, value)) + + // Replace valid param value with invalid one + url.searchParams.set(paramKey, paramValue) + + expect(() => { + parseInvitationLinkDeepUrl(url.href) + }).toThrow() + }) + + it('retrieves invitation data from deep url with partly invalid addresses', () => { + const urlParamsWithInvalidAddress = [ + [data.pairs[0].peerId, data.pairs[0].onionAddress], + [data.pairs[1].peerId, data.pairs[1].onionAddress], + ['QmZoiJNAvCffeEHBjk766nLuKVdkxkAT7wf', 'y7yczmugl2tekami7sbdz5pfaemvx7bahwthrdv'], + [PSK_PARAM_KEY, data.psk], + [OWNER_ORBIT_DB_IDENTITY_PARAM_KEY, data.ownerOrbitDbIdentity], + ] + + const url = new URL(DEEP_URL_SCHEME_WITH_SEPARATOR) + urlParamsWithInvalidAddress.forEach(([key, value]) => url.searchParams.append(key, value)) + + const parsed = parseInvitationLinkDeepUrl(url.href) + expect(parsed).toEqual({ + version: InvitationDataVersion.v1, + ...data, + }) + }) +}) + +describe(`Invitation link helper ${InvitationDataVersion.v2}`, () => { + const address = 'gloao6h5plwjy4tdlze24zzgcxll6upq2ex2fmu2ohhyu4gtys4nrjad' + const peerId = 'QmZoiJNAvCffeEHBjk766nLuKVdkxkAT7wfFJDPPLsbKSE' + const data: InvitationDataV2 = { + ...validInvitationDatav2[0], + pairs: [...validInvitationDatav1[0].pairs, { peerId: peerId, onionAddress: address }], + } + const urlParams = [ + [PEER_ADDRESS_KEY, peerPairsToUrlParamString([data.pairs[0], data.pairs[1]])], + [PSK_PARAM_KEY, data.psk], + [OWNER_ORBIT_DB_IDENTITY_PARAM_KEY, data.ownerOrbitDbIdentity], + [AUTH_DATA_KEY, encodeAuthData(data.authData)], + ] + + it('retrieves invitation link from argv', () => { + const result = argvInvitationLink(['something', 'quiet:/invalid', 'zbay://invalid', composeInvitationDeepUrl(data)]) + expect(result).toEqual(data) + }) + + it('returns null if argv do not contain any url with proper scheme', () => { + const result = argvInvitationLink(['something', 'quiet:/invalid', 'zbay://invalid']) + expect(result).toBeNull() + }) + + it('throws error if argv contains invalid invitation url', () => { + expect(() => { + argvInvitationLink(['something', 'quiet:/invalid', 'quiet://?param=invalid']) + }).toThrow() + }) + + it('composes proper invitation deep url', () => { + const url = new URL(DEEP_URL_SCHEME_WITH_SEPARATOR) + urlParams.forEach(([key, value]) => url.searchParams.append(key, value)) + expect(composeInvitationDeepUrl(data)).toEqual(url.href) + }) + + it('creates invitation share url based on invitation data', () => { + const url = new URL(QUIET_JOIN_PAGE) + urlParams.forEach(([key, value]) => url.searchParams.append(key, value)) + expect(composeInvitationShareUrl(data)).toEqual(url.href.replace('?', '#')) + }) + + it('converts list of p2p addresses to invitation pairs', () => { + const pair: InvitationPair = { + peerId: 'QmZoiJNAvCffeEHBjk766nLuKVdkxkAT7wfFJDPPLsbKSE', + onionAddress: 'gloao6h5plwjy4tdlze24zzgcxll6upq2ex2fmu2ohhyu4gtys4nrjad', + } + const peerList = [ + createLibp2pAddress(pair.onionAddress, pair.peerId), + 'invalidAddress', + createLibp2pAddress('somethingElse.onion', 'QmZoiJNAvCffeEHBjk766nLuKVdkxkAT7wfFJDPPLsbKSA'), + ] + expect(p2pAddressesToPairs(peerList)).toEqual([pair]) + }) + + it('retrieves invitation data from deep url', () => { + const url = new URL(DEEP_URL_SCHEME_WITH_SEPARATOR) + urlParams.forEach(([key, value]) => url.searchParams.append(key, value)) + + const parsed = parseInvitationLinkDeepUrl(url.href) + expect(parsed).toEqual({ + version: InvitationDataVersion.v2, + ...data, + }) + }) + + it('throw error if auth data string is invalid', () => { + const url = new URL(DEEP_URL_SCHEME_WITH_SEPARATOR) + const urlParams = [ + [PEER_ADDRESS_KEY, peerPairsToUrlParamString([data.pairs[0], data.pairs[1]])], + [PSK_PARAM_KEY, data.psk], + [OWNER_ORBIT_DB_IDENTITY_PARAM_KEY, data.ownerOrbitDbIdentity], + [AUTH_DATA_KEY, '()_*'], + ] + urlParams.forEach(([key, value]) => url.searchParams.append(key, value)) + + try { + const parsed = parseInvitationLinkDeepUrl(url.href) + expect(parsed).toBe(null) + } catch (e) { + expect(e.message).toBe(`Invalid value '()_*' for key 'a' in invitation link`) + } + }) + + it('throw error if peer address param is present but no valid addresses are found', () => { + const url = new URL(DEEP_URL_SCHEME_WITH_SEPARATOR) + const urlParams = [ + [PEER_ADDRESS_KEY, 'foobar'], + [PSK_PARAM_KEY, data.psk], + [OWNER_ORBIT_DB_IDENTITY_PARAM_KEY, data.ownerOrbitDbIdentity], + [AUTH_DATA_KEY, encodeAuthData(data.authData)], + ] + urlParams.forEach(([key, value]) => url.searchParams.append(key, value)) + + try { + const parsed = parseInvitationLinkDeepUrl(url.href) + expect(parsed).toBe(null) + } catch (e) { + expect(e.message).toContain(`Invalid value 'foobar' for key 'p' in invitation link`) + } + }) + + // TODO: TECH DEBT: Get rid of when we go to 3.0 + it('LEGACY - throw error if no peer pairs are found as named param or dynamic params', () => { + const url = new URL(DEEP_URL_SCHEME_WITH_SEPARATOR) + const urlParams = [ + [PSK_PARAM_KEY, data.psk], + [OWNER_ORBIT_DB_IDENTITY_PARAM_KEY, data.ownerOrbitDbIdentity], + [AUTH_DATA_KEY, encodeAuthData(data.authData)], + ] + urlParams.forEach(([key, value]) => url.searchParams.append(key, value)) + + try { + const parsed = parseInvitationLinkDeepUrl(url.href) + expect(parsed).toBe(null) + } catch (e) { + expect(e.message).toContain(`No valid peer addresses found in invitation link`) + } + }) + + it('throw error if community name is invalid', () => { + const url = new URL(DEEP_URL_SCHEME_WITH_SEPARATOR) + const urlParams = [ + [PEER_ADDRESS_KEY, peerPairsToUrlParamString([data.pairs[0], data.pairs[1]])], + [PSK_PARAM_KEY, data.psk], + [OWNER_ORBIT_DB_IDENTITY_PARAM_KEY, data.ownerOrbitDbIdentity], + [ + AUTH_DATA_KEY, + encodeAuthData({ + ...data.authData, + communityName: '()_*', + }), + ], + ] + urlParams.forEach(([key, value]) => url.searchParams.append(key, value)) + + try { + const parsed = parseInvitationLinkDeepUrl(url.href) + expect(parsed).toBe(null) + } catch (e) { + expect(e.message).toBe(`Invalid value '()_*' for key 'a.c' in invitation link`) + } + }) + + it('throw error if seed is invalid', () => { + const url = new URL(DEEP_URL_SCHEME_WITH_SEPARATOR) + const urlParams = [ + [PEER_ADDRESS_KEY, peerPairsToUrlParamString([data.pairs[0], data.pairs[1]])], + [PSK_PARAM_KEY, data.psk], + [OWNER_ORBIT_DB_IDENTITY_PARAM_KEY, data.ownerOrbitDbIdentity], + [ + AUTH_DATA_KEY, + encodeAuthData({ + ...data.authData, + seed: 'ABC!@#!@#!@#!#!@', + }), + ], + ] + urlParams.forEach(([key, value]) => url.searchParams.append(key, value)) + + try { + const parsed = parseInvitationLinkDeepUrl(url.href) + expect(parsed).toBe(null) + } catch (e) { + expect(e.message).toBe(`Invalid value 'ABC!@#!@#!@#!#!@' for key 'a.s' in invitation link`) + } + }) + + it.each([ + [PSK_PARAM_KEY, '12345'], + [PSK_PARAM_KEY, 'a2FzemE='], + [PSK_PARAM_KEY, 'a2FycGllIHcgZ2FsYXJlY2llIGVjaWUgcGVjaWUgYWxlIGkgdGFrIHpqZWNpZQ=='], + ])('parsing deep url throws error if data is invalid: %s=%s', (paramKey: string, paramValue: string) => { + const url = new URL(DEEP_URL_SCHEME_WITH_SEPARATOR) + urlParams.forEach(([key, value]) => url.searchParams.append(key, value)) + + // Replace valid param value with invalid one + url.searchParams.set(paramKey, paramValue) + + expect(() => { + parseInvitationLinkDeepUrl(url.href) + }).toThrow() + }) + + it('retrieves invitation data from deep url with partly invalid addresses', () => { + const urlParamsWithInvalidAddress = [ + [ + PEER_ADDRESS_KEY, + peerPairsToUrlParamString([ + data.pairs[0], + data.pairs[1], + { + peerId: 'QmZoiJNAvCffeEHBjk766nLuKVdkxkAT7wf', + onionAddress: 'y7yczmugl2tekami7sbdz5pfaemvx7bahwthrdv', + }, + ]), + ], + [PSK_PARAM_KEY, data.psk], + [OWNER_ORBIT_DB_IDENTITY_PARAM_KEY, data.ownerOrbitDbIdentity], + [AUTH_DATA_KEY, encodeAuthData(data.authData)], + ] + + const url = new URL(DEEP_URL_SCHEME_WITH_SEPARATOR) + urlParamsWithInvalidAddress.forEach(([key, value]) => url.searchParams.append(key, value)) + + const parsed = parseInvitationLinkDeepUrl(url.href) + expect(parsed).toEqual({ + version: InvitationDataVersion.v2, + ...data, + }) + }) + + // TODO: TECH DEBT: Get rid of when we go to 3.0 + it('LEGACY - retrieves invitation data from url with dynamic peer address params', () => { + const urlParamsWithDynamicPeerParams = [ + [data.pairs[0].peerId, data.pairs[0].onionAddress], + [data.pairs[1].peerId, data.pairs[1].onionAddress], + [PSK_PARAM_KEY, data.psk], + [OWNER_ORBIT_DB_IDENTITY_PARAM_KEY, data.ownerOrbitDbIdentity], + [AUTH_DATA_KEY, encodeAuthData(data.authData)], + ] + + const url = new URL(DEEP_URL_SCHEME_WITH_SEPARATOR) + urlParamsWithDynamicPeerParams.forEach(([key, value]) => url.searchParams.append(key, value)) + + const parsed = parseInvitationLinkDeepUrl(url.href) + expect(parsed).toEqual({ + version: InvitationDataVersion.v2, + ...data, + }) + }) +}) diff --git a/packages/common/src/invitationLink/invitationLink.ts b/packages/common/src/invitationLink/invitationLink.ts new file mode 100644 index 0000000000..ce19397cb9 --- /dev/null +++ b/packages/common/src/invitationLink/invitationLink.ts @@ -0,0 +1,273 @@ +import { InvitationData, InvitationDataV1, InvitationDataV2, InvitationDataVersion, InvitationPair } from '@quiet/types' +import { QUIET_JOIN_PAGE } from '../const' +import { + AUTH_DATA_KEY, + DEEP_URL_SCHEME, + DEEP_URL_SCHEME_WITH_SEPARATOR, + OWNER_ORBIT_DB_IDENTITY_PARAM_KEY, + PEER_ADDRESS_KEY, + PSK_PARAM_KEY, +} from './invitationLink.const' +import { + encodeAuthData, + PARAM_CONFIG_V1, + PARAM_CONFIG_V2, + validatePeerData, + parseAndValidateUrlParams, +} from './invitationLink.validator' +import { createLibp2pAddress } from '../libp2p' +import { createLogger } from '../logger' + +const logger = createLogger('invite') + +interface ParseDeepUrlParams { + url: string + expectedProtocol?: string +} + +/** + * Parse and validate the URL parameters on a given V2 (LFA) invite link URL + * + * @param url V2 invite link URL to validate parameters on + * + * @returns {InvitationDataV2} Parsed V2 parameters + */ +const parseLinkV2 = (url: string): InvitationDataV2 => { + /** + * =&=...&k=&o=&a=&s=`) + */ + return parseAndValidateUrlParams(url, PARAM_CONFIG_V2) +} + +/** + * Parse and validate the URL parameters on a given V1 (non-LFA) invite link URL + * + * @param url V1 invite link URL to validate parameters on + * + * @returns {InvitationDataV1} Parsed V1 parameters + */ +const parseLinkV1 = (url: string): InvitationDataV1 => { + /** + * =&=...&k=&o= + */ + return parseAndValidateUrlParams(url, PARAM_CONFIG_V1) +} + +/** + * Extract invitation data from deep url. + * Valid format: quiet://?=&=&k= + * + * @param deepUrlOptions Object containing the deep URL to parse and the URL protocol + * + * @returns {InvitationData} Parsed parameters + */ +const parseDeepUrl = ({ url, expectedProtocol = `${DEEP_URL_SCHEME}:` }: ParseDeepUrlParams): InvitationData => { + let _url = url + let validUrl: URL | null = null + + if (!expectedProtocol) { + // Create a full url to be able to use the same URL parsing mechanism + expectedProtocol = `${DEEP_URL_SCHEME}:` + _url = `${DEEP_URL_SCHEME_WITH_SEPARATOR}?${url}` + } + + try { + validUrl = new URL(_url) + } catch (e) { + logger.error(`Could not retrieve invitation data from deep url '${url}'. Reason: ${e.message}`) + throw e + } + if (!validUrl || validUrl.protocol !== expectedProtocol) { + logger.error(`Could not retrieve invitation data from deep url '${url}'`) + throw new Error(`Invalid url`) + } + + const params = validUrl.searchParams + + const psk = params.get(PSK_PARAM_KEY) + const authData = params.get(AUTH_DATA_KEY) + if (!psk) throw new Error(`Invitation link does not match either v1 or v2 format '${url}'`) + + let data: InvitationData | null = null + if (psk != null && authData == null) { + data = parseLinkV1(_url) + } else if (psk != null && authData != null) { + data = parseLinkV2(_url) + } + + if (!data) throw new Error(`Could not parse invitation data from deep url '${url}'`) + + logger.info(`Invitation data '${JSON.stringify(data)}' parsed`) + return data +} + +/** + * Extract invitation data from deep url. + * Valid format: quiet://?=&=&k= + * + * @param url V1 or V2 invite link URL to validate parameters on + * + * @returns {InvitationData} Parsed parameters + */ +export const parseInvitationLinkDeepUrl = (url: string): InvitationData => { + return parseDeepUrl({ url }) +} + +/** + * @param link =&=&k= + * + * @param url V1 or V2 invite link URL to validate parameters on + * + * @returns {InvitationData} Parsed parameters + */ +export const parseInvitationLink = (link: string): InvitationData => { + return parseDeepUrl({ url: link, expectedProtocol: '' }) +} + +/** + * Convert an array of peer addresses to an array of peer ID/onion address pairs + * + * @param addresses Array of peer addresses to parse and validate + * + * @returns {InvitationPair[]} Parsed and validated peer data + */ +export const p2pAddressesToPairs = (addresses: string[]): InvitationPair[] => { + /** + * @arg {string[]} addresses - List of peer's p2p addresses + */ + const pairs: InvitationPair[] = [] + for (const peerAddress of addresses) { + let peerId: string + let onionAddress: string + try { + peerId = peerAddress.split('/p2p/')[1] + } catch (e) { + logger.error(`Could not add peer address '${peerAddress}' to invitation url.`, e) + continue + } + try { + onionAddress = peerAddress.split('/tcp/')[0].split('/dns4/')[1] + } catch (e) { + logger.error(`Could not add peer address '${peerAddress}' to invitation url.`, e) + continue + } + + if (!peerId || !onionAddress) { + logger.error(`No peerId or address in ${peerAddress}`) + continue + } + const rawAddress = onionAddress.endsWith('.onion') ? onionAddress.split('.')[0] : onionAddress + if (!validatePeerData({ peerId, onionAddress: rawAddress })) continue + + pairs.push({ peerId: peerId, onionAddress: rawAddress }) + } + return pairs +} + +/** + * Convert an array of InvitationPair objects to an array of complete peer addresses + * + * @param pairs Array of InvitationPair objects + * + * @returns {string[]} Peer addresses formed from InvitationPairs + */ +export const pairsToP2pAddresses = (pairs: InvitationPair[]): string[] => { + const addresses: string[] = [] + for (const pair of pairs) { + addresses.push(createLibp2pAddress(pair.onionAddress, pair.peerId)) + } + return addresses +} + +/** + * Convert an InvitationData object to valid invite link URL parameters and return a completed shareable invite link + * + * Example: https://tryquiet.org/join/#=&=&k=&o= + * + * @param data InvitationData object representing the URL parameters on a new invite link URL + * + * @returns {string} Complete shareable invitation link + */ +export const composeInvitationShareUrl = (data: InvitationData): string => { + return composeInvitationUrl(`${QUIET_JOIN_PAGE}`, data).replace('?', '#') +} + +/** + * Convert an InvitationData object to valid invite link URL parameters and return a completed deep invite link + * + * Example: quiet://?=&=&k=&o= + * + * @param data InvitationData object representing the URL parameters on a new invite link URL + * + * @returns {string} Complete shareable invitation link + */ +export const composeInvitationDeepUrl = (data: InvitationData): string => { + return composeInvitationUrl(`${DEEP_URL_SCHEME_WITH_SEPARATOR}`, data) +} + +export const peerPairsToUrlParamString = (pairs: InvitationPair[]): string => { + const commaSeparatedPairs: string[] = [] + for (const pair of pairs) { + commaSeparatedPairs.push(`${pair.peerId},${pair.onionAddress}`) + } + return commaSeparatedPairs.join(';') +} + +/** + * Given a base URL (e.g. `quiet://`) and an InvitationData object determine the version of the invite data and + * convert to URL parameters and return the completed invite link + * + * @param baseUrl Base URL for shareable or deep invite link URLs + * @param data InvitationData object representing the URL parameters on a new invite link URL + * + * @returns {string} Complete invite link URL + */ +const composeInvitationUrl = (baseUrl: string, data: InvitationDataV1 | InvitationDataV2): string => { + const url = new URL(baseUrl) + + if (!data.version) data.version = InvitationDataVersion.v1 + + switch (data.version) { + case InvitationDataVersion.v1: + url.searchParams.append(PEER_ADDRESS_KEY, peerPairsToUrlParamString(data.pairs)) + url.searchParams.append(PSK_PARAM_KEY, data.psk) + url.searchParams.append(OWNER_ORBIT_DB_IDENTITY_PARAM_KEY, data.ownerOrbitDbIdentity) + break + case InvitationDataVersion.v2: + url.searchParams.append(PEER_ADDRESS_KEY, peerPairsToUrlParamString(data.pairs)) + url.searchParams.append(PSK_PARAM_KEY, data.psk) + url.searchParams.append(OWNER_ORBIT_DB_IDENTITY_PARAM_KEY, data.ownerOrbitDbIdentity) + url.searchParams.append(AUTH_DATA_KEY, encodeAuthData(data.authData)) + } + return url.href +} + +/** + * Extract invitation codes from deep url if url is present in argv + * + * @param argv Command line arguments to parse + * + * @returns {InvitationData | null} Parsed and validated invite link URL parameters as InvitationData (if invite link present in args) + */ +export const argvInvitationLink = (argv: string[]): InvitationData | null => { + let invitationData: InvitationData | null = null + for (const arg of argv) { + if (!arg.startsWith(DEEP_URL_SCHEME_WITH_SEPARATOR)) { + logger.warn('Not a deep url, not parsing', arg) + continue + } + logger.info('Parsing deep url', arg) + invitationData = parseInvitationLinkDeepUrl(arg) + switch (invitationData.version) { + case InvitationDataVersion.v1: + case InvitationDataVersion.v2: + if (invitationData.pairs.length > 0) { + break + } else { + invitationData = null + } + } + } + return invitationData +} diff --git a/packages/common/src/invitationLink/invitationLink.validator.ts b/packages/common/src/invitationLink/invitationLink.validator.ts new file mode 100644 index 0000000000..6660ef0397 --- /dev/null +++ b/packages/common/src/invitationLink/invitationLink.validator.ts @@ -0,0 +1,500 @@ +import { + InvitationAuthData, + InvitationData, + InvitationDataV1, + InvitationDataV2, + InvitationDataVersion, + InvitationLinkUrlNamedParamConfig, + InvitationLinkUrlNamedParamConfigMap, + InvitationLinkUrlNamedParamProcessorFun, + InvitationLinkUrlNamedParamValidatorFun, + InvitationPair, + VersionedInvitationLinkUrlParamConfig, +} from '@quiet/types' +import { + AUTH_DATA_KEY, + AUTH_DATA_OBJECT_KEY, + COMMUNITY_NAME_KEY, + DEEP_URL_SCHEME_WITH_SEPARATOR, + INVITATION_SEED_KEY, + OWNER_ORBIT_DB_IDENTITY_PARAM_KEY, + PEER_ADDRESS_KEY, + PSK_PARAM_KEY, +} from './invitationLink.const' +import { isPSKcodeValid } from '../libp2p' +import { createLogger } from '../logger' + +const logger = createLogger('invite:validator') + +const ONION_ADDRESS_REGEX = /^[a-z0-9]{56}$/g +const PEER_ID_REGEX = /^[a-zA-Z0-9]{46}$/g +const INVITATION_SEED_REGEX = /^[a-zA-Z0-9]{16}$/g +const COMMUNITY_NAME_REGEX = /^[-a-zA-Z0-9 ]+$/g +const AUTH_DATA_REGEX = /^[A-Za-z0-9_-]+$/g + +/** + * Helper Error class for generating validation errors in a standard format + */ +export class UrlParamValidatorError extends Error { + name = 'UrlParamValidatorError' + + constructor(key: string, value: string | null | undefined) { + super(`Invalid value '${value}' for key '${key}' in invitation link`) + } +} + +/** + * Encode an InvitationAuthData object as a base64url-encoded URL param string + * + * Example: + * + * { + * "communityName": "community-name", + * "seed": "4kgd5mwq5z4fmfwq" + * } + * + * => c=community-name&s=4kgd5mwq5z4fmfwq => Yz1jb21tdW5pdHktbmFtZSZzPTRrZ2Q1bXdxNXo0Zm1md3E + * + * @param authData InvitationAuthData object to encode + * + * @returns {string} Base64url-encoded string + */ +export const encodeAuthData = (authData: InvitationAuthData): string => { + const encodedAuthData = `${COMMUNITY_NAME_KEY}=${encodeURIComponent(authData.communityName)}&${INVITATION_SEED_KEY}=${encodeURIComponent(authData.seed)}` + return Buffer.from(encodedAuthData, 'utf8').toString('base64url') +} + +/** + * Decodes a base64url-encoded string and creates a fake-URL for parsing and validation + * + * Example: + * + * Yz1jb21tdW5pdHktbmFtZSZzPTRrZ2Q1bXdxNXo0Zm1md3E => quiet://?c=community-name&s=4kgd5mwq5z4fmfwq + * + * @param authDataString Base64url-encoded string representing the InvitationAuthData of the invite link + * + * @returns {string} URL-encoded string of the InvitationAuthData object as URL with parameters + */ +export const decodeAuthData: InvitationLinkUrlNamedParamProcessorFun = (authDataString: string): string => { + return `${DEEP_URL_SCHEME_WITH_SEPARATOR}?${Buffer.from(authDataString, 'base64url').toString('utf8')}` +} + +/** + * Validate that the peer ID and onion address provided in the invite link are of the correct form + * + * @param peerData The peer ID and onion address to validate + * + * @returns {boolean} `true` if the data is valid, else false + */ +export const validatePeerData = ({ peerId, onionAddress }: { peerId: string; onionAddress: string }): boolean => { + if (!peerId.match(PEER_ID_REGEX)) { + // TODO: test it more properly e.g with PeerId.createFromB58String(peerId.trim()) + logger.warn(`PeerId ${peerId} is not valid`) + return false + } + + if (!onionAddress.trim().match(ONION_ADDRESS_REGEX)) { + logger.warn(`Onion address ${onionAddress} is not valid`) + return false + } + + return true +} + +// TODO: TECH DEBT: Get rid of when we go to 3.0 +/** + * **** LEGACY - This is only here to handle older invite links **** + * + * Validate all peer data pairs on an invite link URL + * + * @param url Invite link URL to validate peer data on + * @param unnamedParams Parameters that were not previously parsed and validated + * + * @returns {InvitationPair[]} Validated InvitationPairs + */ +const validatePeerPairsFromUrlParams = (url: string, unnamedParams: URLSearchParams): InvitationPair[] => { + const pairs: InvitationPair[] = [] + + unnamedParams.forEach((onionAddress, peerId) => { + if (!validatePeerData({ peerId, onionAddress })) return + pairs.push({ + peerId, + onionAddress, + }) + }) + + if (pairs.length === 0) { + throw new Error(`No valid peer addresses found in invitation link '${url}'`) + } + + return pairs +} + +/** + * Validate the format of the provided PSK + * + * Example: + * + * BNlxfE2WBF7LrlpIX0CvECN5o1oZtA16PkAb7GYiwYw= + * + * => + * + * { + * "psk": "BNlxfE2WBF7LrlpIX0CvECN5o1oZtA16PkAb7GYiwYw=" + * } + * + * @param value PSK string pulled from invite link + * + * @returns {Partial} The processed PSK represented as a partial InvitationData object + */ +const validatePsk: InvitationLinkUrlNamedParamValidatorFun = ( + value: string +): Partial => { + if (!isPSKcodeValid(value)) { + logger.warn(`PSK is null or not a valid PSK code`) + throw new UrlParamValidatorError(PSK_PARAM_KEY, value) + } + + return { + psk: value, + } +} + +/** + * Validate the format of the provided owner's OrbitDB identity string + * + * NOTE: currently we do no actual validation on this parameter other than the non-null check in _parseAndValidateNamedParam + * + * Example: + * + * Yz1jb21tdW5pdHktbmFtZSZzPTRrZ2Q1bXdxNXo0Zm1md3E + * + * => + * + * { + * "ownerOrbitDbIdentity": "018f9e87541d0b61cb4565af8df9699f658116afc54ae6790c31bbf6df3fc343b0" + * } + * + * @param value Owner's OrbitDB identity string pulled from invite link + * + * @returns {Partial} The processed owner OrbitDB identity represented as a partial InvitationData object + */ +const validateOwnerOrbitDbIdentity: InvitationLinkUrlNamedParamValidatorFun = ( + value: string +): Partial => { + return { + ownerOrbitDbIdentity: value, + } +} + +/** + * Validate the format of the provided owner's OrbitDB identity string + * + * NOTE: currently we do no actual validation on this parameter other than the non-null check in _parseAndValidateNamedParam + * + * Example: + * + * Yz1jb21tdW5pdHktbmFtZSZzPTRrZ2Q1bXdxNXo0Zm1md3E + * + * => + * + * { + * "ownerOrbitDbIdentity": "018f9e87541d0b61cb4565af8df9699f658116afc54ae6790c31bbf6df3fc343b0" + * } + * + * @param value Owner's OrbitDB identity string pulled from invite link + * + * @returns {Partial} The processed owner OrbitDB identity represented as a partial InvitationData object + */ +const validatePeerAddresses: InvitationLinkUrlNamedParamValidatorFun = ( + value: string +): Partial => { + const pairs: InvitationPair[] = [] + + const stringPairs = value.split(';') + stringPairs.forEach(stringPair => { + const [peerId, onionAddress] = stringPair.split(',') + if (!validatePeerData({ peerId, onionAddress })) return + pairs.push({ + peerId, + onionAddress, + }) + }) + + if (pairs.length === 0) { + logger.warn(`Peer address string contained no ID/address pairs`) + throw new UrlParamValidatorError(PEER_ADDRESS_KEY, value) + } + + return { + pairs, + } +} + +/** + * Parse and validate the provided auth data string + * + * Example: + * + * BNlxfE2WBF7LrlpIX0CvECN5o1oZtA16PkAb7GYiwYw= + * + * => + * + * { + * "authData": { + * "communityName": "community-name", + * "seed": "4kgd5mwq5z4fmfwq" + * } + * } + * + * @param value Auth data string pulled from invite link + * + * @returns {Partial} The processed auth data represented as a partial InvitationData object + */ +const validateAuthData: InvitationLinkUrlNamedParamValidatorFun = (value: string): string => { + if (value.match(AUTH_DATA_REGEX) == null) { + logger.warn(`Auth data string is not a valid base64url-encoded string`) + throw new UrlParamValidatorError(AUTH_DATA_KEY, value) + } + return decodeAuthData(value) +} + +/** + * **** NESTED VALIDATOR **** + * + * Parse and validate the provided LFA invitation seed string + * + * Example: + * + * 4kgd5mwq5z4fmfwq + * + * => + * + * { + * "seed": "4kgd5mwq5z4fmfwq" + * } + * + * @param value Nested LFA invitation seed string pulled from the decoded auth data string + * + * @returns {Partial} The processed LFA invitation seed represented as a partial InvitationAuthData object + */ +const validateInvitationSeed: InvitationLinkUrlNamedParamValidatorFun = ( + value: string +): Partial => { + if (value.match(INVITATION_SEED_REGEX) == null) { + logger.warn(`Invitation seed ${value} is not a valid LFA seed`) + throw new UrlParamValidatorError(`${AUTH_DATA_KEY}.${INVITATION_SEED_KEY}`, value) + } + return { + seed: value, + } +} + +/** + * **** NESTED VALIDATOR **** + * + * Parse and validate the provided community name string + * + * Example: + * + * community-name + * + * => + * + * { + * "communityName": "community-name" + * } + * + * @param value Nested community name string pulled from the decoded auth data string + * @param processor Optional post-processor to run the validated value through + * + * @returns {Partial} The processed community name represented as a partial InvitationAuthData object + */ +const validateCommunityName: InvitationLinkUrlNamedParamValidatorFun = ( + value: string +): Partial => { + if (value.match(COMMUNITY_NAME_REGEX) == null) { + logger.warn(`Community name ${value} is not a valid Quiet community name`) + throw new UrlParamValidatorError(`${AUTH_DATA_KEY}.${COMMUNITY_NAME_KEY}`, value) + } + return { + communityName: value, + } +} + +/** + * URL param validation config for V1 (non-LFA) invite links + */ +export const PARAM_CONFIG_V1: VersionedInvitationLinkUrlParamConfig = { + version: InvitationDataVersion.v1, + named: new Map( + Object.entries({ + [PSK_PARAM_KEY]: { + required: true, + validator: validatePsk, + }, + [OWNER_ORBIT_DB_IDENTITY_PARAM_KEY]: { + required: true, + validator: validateOwnerOrbitDbIdentity, + }, + [PEER_ADDRESS_KEY]: { + required: false, + validator: validatePeerAddresses, + }, + }) + ), +} + +/** + * URL param validation config for V2 (LFA) invite links + */ +export const PARAM_CONFIG_V2: VersionedInvitationLinkUrlParamConfig = { + version: InvitationDataVersion.v2, + named: new Map( + Object.entries({ + [PSK_PARAM_KEY]: { + required: true, + validator: validatePsk, + }, + [OWNER_ORBIT_DB_IDENTITY_PARAM_KEY]: { + required: true, + validator: validateOwnerOrbitDbIdentity, + }, + [PEER_ADDRESS_KEY]: { + required: false, + validator: validatePeerAddresses, + }, + [AUTH_DATA_KEY]: { + required: true, + validator: validateAuthData, + nested: { + key: AUTH_DATA_OBJECT_KEY, + config: new Map( + Object.entries({ + [COMMUNITY_NAME_KEY]: { + required: true, + validator: validateCommunityName, + }, + [INVITATION_SEED_KEY]: { + required: true, + validator: validateInvitationSeed, + }, + }) + ), + }, + }, + }) + ), +} + +/** + * Parse and validate a given URL param from an invite link URL and put it into the form of an InvitationData object + * + * Example: + * + * Given a key-value pair `a=Yz1jb21tdW5pdHktbmFtZSZzPTRrZ2Q1bXdxNXo0Zm1md3E` the returned value would be + * + * { + * "authData": { + * "communityName": "community-name", + * "seed": "4kgd5mwq5z4fmfwq" + * } + * } + * + * @param key URL param key + * @param value Value of URL param with the given key + * @param config The validation config for this param + * + * @returns {Partial} The processed URL param represented as a partial InvitationData object + */ +const _parseAndValidateNamedParam = ( + key: string, + value: string | null | undefined, + config: InvitationLinkUrlNamedParamConfig +): any | undefined => { + if (value == null) { + if (config.required) throw new Error(`Missing required key '${key}' in invitation link`) + return undefined + } + + return config.validator(value) +} + +/** + * Parse and validate named URL parameters recursively + * + * Example: + * + * quiet://?QmZoiJNAvCffeEHBjk766nLuKVdkxkAT7wfFJDPPLsbKSE=y7yczmugl2tekami7sbdz5pfaemvx7bahwthrdvcbzw5vex2crsr26qd&QmZoiJNAvCffeEHBjk766nLuKVdkxkAT7wfFJDPPLsbKSE=gloao6h5plwjy4tdlze24zzgcxll6upq2ex2fmu2ohhyu4gtys4nrjad&k=BNlxfE2WBF7LrlpIX0CvECN5o1oZtA16PkAb7GYiwYw%3D&o=018f9e87541d0b61cb4565af8df9699f658116afc54ae6790c31bbf6df3fc343b0&a=Yz1jb21tdW5pdHktbmFtZSZzPTRrZ2Q1bXdxNXo0Zm1md3E + * + * The value of `a` is a base64url-encoded string that decodes to `c=community-name&s=4kgd5mwq5z4fmfwq` and _parseAndValidateUrlParams will recursively parse and validate the nested params + * + * @param params List of named URL params pulled from the invite link URL + * @param paramConfigMap Map of URL params that are expected on this invite URL + * + * @returns { output: Partial; params: URLSearchParams } Object built from all named URL parameters and the remaining parameters + */ +const _parseAndValidateNamedUrlParams = ( + params: URLSearchParams, + paramConfigMap: InvitationLinkUrlNamedParamConfigMap +): { output: Partial; params: URLSearchParams } => { + let output: Partial = {} + for (const pc of paramConfigMap.entries()) { + const [key, config] = pc + let value = _parseAndValidateNamedParam(key, params.get(key), config) + if (value == null) { + continue + } + + if (config.nested) { + const nestedParams = new URL(value).searchParams + const { output: nestedValue } = _parseAndValidateNamedUrlParams(nestedParams, config.nested.config) + value = { + [config.nested.key]: nestedValue, + } + } + output = { + ...output, + ...value, + } + params.delete(key) + } + + return { + output, + params, + } +} + +/** + * Parse and validate URL parameters on an invitation link URL + * + * @param url Invite link URL + * @param paramConfigMap Map of named URL params that are expected on this invite URL + * + * @returns {InvitationData} Parsed URL params + */ +export const parseAndValidateUrlParams = ( + url: string, + paramConfigMap: VersionedInvitationLinkUrlParamConfig +): T => { + const params = new URL(url).searchParams + const { output, params: remainingParams } = _parseAndValidateNamedUrlParams( + params, + paramConfigMap.named + ) + + // TODO: TECH DEBT: Get rid of when we go to 3.0 + // To keep this backwards compatible we should check if peer pairs were found using the named key and try to pull them from + // dynamic params instead + let pairs: InvitationPair[] | undefined = output.pairs + if (pairs == null && paramConfigMap.named.get(PEER_ADDRESS_KEY) != null) { + pairs = validatePeerPairsFromUrlParams(url, remainingParams) + } + + return { + ...output, + pairs, + version: paramConfigMap.version, + } as T +} diff --git a/packages/common/src/tests.ts b/packages/common/src/tests.ts index 604d0f6ca9..6eead0dfd8 100644 --- a/packages/common/src/tests.ts +++ b/packages/common/src/tests.ts @@ -1,5 +1,5 @@ import { InvitationData, InvitationDataV1, InvitationDataV2, InvitationDataVersion } from '@quiet/types' -import { composeInvitationDeepUrl, composeInvitationShareUrl } from './invitationCode' +import { composeInvitationDeepUrl, composeInvitationShareUrl } from './invitationLink/invitationLink' import { QUIET_JOIN_PAGE } from './const' export const validInvitationDatav1: InvitationDataV1[] = [ @@ -28,10 +28,33 @@ export const validInvitationDatav1: InvitationDataV1[] = [ export const validInvitationDatav2: InvitationDataV2[] = [ { version: InvitationDataVersion.v2, - cid: '12D3KooWSYQf8zzr5rYnUdLxYyLzHruQHPaMssja1ADifGAcN3qY', - token: 'BNlxfE2WBF7LrlpIX0CvECN5o1oZtA16PkAb7GYiwYw', - serverAddress: 'https://tryquiet.org/api/', - inviterAddress: 'pgzlcstu4ljvma7jqyalimcxlvss5bwlbba3c3iszgtwxee4qjdlgeqd', + pairs: [ + { + onionAddress: 'y7yczmugl2tekami7sbdz5pfaemvx7bahwthrdvcbzw5vex2crsr26qd', + peerId: 'QmZoiJNAvCffeEHBjk766nLuKVdkxkAT7wfFJDPPLsbKSE', + }, + ], + psk: 'BNlxfE2WBF7LrlpIX0CvECN5o1oZtA16PkAb7GYiwYw=', + ownerOrbitDbIdentity: '018f9e87541d0b61cb4565af8df9699f658116afc54ae6790c31bbf6df3fc343b0', + authData: { + communityName: 'community-name', + seed: '4kgd5mwq5z4fmfwq', + }, + }, + { + version: InvitationDataVersion.v2, + pairs: [ + { + onionAddress: 'pgzlcstu4ljvma7jqyalimcxlvss5bwlbba3c3iszgtwxee4qjdlgeqd', + peerId: 'QmaRchXhkPWq8iLiMZwFfd2Yi4iESWhAYYJt8cTCVXSwpG', + }, + ], + psk: '5T9GBVpDoRpKJQK4caDTz5e5nym2zprtoySL2oLrzr4=', + ownerOrbitDbIdentity: '028f9e87541d0b61cb4565af8df9699f658116afc54ae6790c31bbf6df3fc343b0', + authData: { + communityName: 'other-community-name', + seed: '6k6damwb3z1emfqw', + }, }, ] @@ -44,7 +67,7 @@ type TestData = { data: T } -export function getValidInvitationUrlTestData(data: T): TestData { +export function getValidInvitationUrlTestData(data: T): TestData { return { shareUrl: () => composeInvitationShareUrl(data), deepUrl: () => composeInvitationDeepUrl(data), diff --git a/packages/desktop/CHANGELOG.md b/packages/desktop/CHANGELOG.md index 02777cda37..eac559ccc7 100644 --- a/packages/desktop/CHANGELOG.md +++ b/packages/desktop/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## [2.3.2] + +### Chores + +* Moved some responsibilities of identity management to the backend ([#2602](https://github.com/TryQuiet/quiet/issues/2602)) +* Added auth submodule in preparation for future encyrption work ([#2623](https://github.com/TryQuiet/quiet/issues/2623)) + +### Fixes + +* Fixed memory leak associated with autoUpdater ([#2606](https://github.com/TryQuiet/quiet/issues/2606)) +* Fixed visual regression tests ([#2644](https://github.com/TryQuiet/quiet/issues/2645)) + ## [2.3.1] ### Fixes diff --git a/packages/desktop/package-lock.json b/packages/desktop/package-lock.json index 8563773e72..a9cfc126cb 100644 --- a/packages/desktop/package-lock.json +++ b/packages/desktop/package-lock.json @@ -1,12 +1,12 @@ { "name": "@quiet/desktop", - "version": "2.3.1", + "version": "2.3.2", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@quiet/desktop", - "version": "2.3.1", + "version": "2.3.2", "license": "GPL-3.0-or-later", "dependencies": { "@electron/remote": "^2.0.8", diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 1ec1713d23..7a049d697d 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -80,7 +80,7 @@ }, "homepage": "https://github.com/TryQuiet", "@comment version": "To build new version for specific platform, just replace platform in version tag to one of following linux, mac, windows", - "version": "2.3.1", + "version": "2.3.2", "description": "Decentralized team chat", "main": "dist/main/main.js", "scripts": { diff --git a/packages/desktop/src/renderer/components/MathMessage/MathMessageComponent.test.tsx b/packages/desktop/src/renderer/components/MathMessage/MathMessageComponent.test.tsx index c58c307968..779c3e058c 100644 --- a/packages/desktop/src/renderer/components/MathMessage/MathMessageComponent.test.tsx +++ b/packages/desktop/src/renderer/components/MathMessage/MathMessageComponent.test.tsx @@ -107,7 +107,7 @@ describe('MathMessageComponent', () => {
It is @@ -187,7 +187,7 @@ describe('MathMessageComponent', () => { and @@ -484,7 +484,7 @@ describe('MathMessageComponent', () => {
    { class="MuiTypography-root MuiTypography-body2 InviteToCommunitylink css-16d47hw-MuiTypography-root" data-testid="invitation-link" > - https://tryquiet.org/join#12D3KooWSYQf8zzr5rYnUdLxYyLzHruQHPaMssja1ADifGAcN3qY=p3oqdr53dkgg3n5nuezlzyawhxvit5efxzlunvzp7n7lmva6fj3i43ad&k=12345&o=testOwnerOrbitDbIdentity + https://tryquiet.org/join#p=QmVTkUad2Gq3MkCa8gf12R1gsWDfk2yiTEqb6YGXDG2iQ3%2Cp3oqdr53dkgg3n5nuezlzyawhxvit5efxzlunvzp7n7lmva6fj3i43ad&k=12345&o=testOwnerOrbitDbIdentity