From 1b0df2d08236ce7068d00b964cff271233287c46 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Thu, 29 Jan 2026 13:30:48 +1100 Subject: [PATCH] feat: add license integration (#2346) Changes: - Adds integration for the license server. - Prevent adding flags that the instance is not allowed to add --- .env.example | 3 + .gitignore | 4 + .../users/licenses/enterprise-edition.mdx | 57 ++- .../public/images/admin-license-status.webp | Bin 0 -> 76524 bytes .../dialogs/claim-create-dialog.tsx | 8 +- .../dialogs/claim-update-dialog.tsx | 5 +- .../forms/subscription-claim-form.tsx | 87 ++-- .../components/general/admin-license-card.tsx | 212 ++++++++++ .../general/admin-license-status-banner.tsx | 78 ++++ .../app/components/general/metric-card.tsx | 17 +- .../components/tables/admin-claims-table.tsx | 14 +- apps/remix/app/root.tsx | 15 +- .../routes/_authenticated+/admin+/_layout.tsx | 14 +- .../routes/_authenticated+/admin+/claims.tsx | 19 +- .../admin+/organisations.$id.tsx | 110 +++-- .../routes/_authenticated+/admin+/stats.tsx | 10 + apps/remix/server/router.ts | 4 + .../enterprise-feature-restrictions.spec.ts | 326 +++++++++++++++ .../e2e/license/license-status-banner.spec.ts | 392 ++++++++++++++++++ packages/app-tests/playwright.config.ts | 13 +- packages/ee/FEATURES | 1 + .../lib/server-only/license/license-client.ts | 229 ++++++++++ packages/lib/types/license.ts | 83 ++++ packages/lib/types/subscription.ts | 6 + .../server/admin-router/resync-license.ts | 17 + .../admin-router/resync-license.types.ts | 8 + packages/trpc/server/admin-router/router.ts | 4 + packages/tsconfig/process-env.d.ts | 1 + turbo.json | 1 + 29 files changed, 1645 insertions(+), 93 deletions(-) create mode 100644 apps/documentation/public/images/admin-license-status.webp create mode 100644 apps/remix/app/components/general/admin-license-card.tsx create mode 100644 apps/remix/app/components/general/admin-license-status-banner.tsx create mode 100644 packages/app-tests/e2e/license/enterprise-feature-restrictions.spec.ts create mode 100644 packages/app-tests/e2e/license/license-status-banner.spec.ts create mode 100644 packages/lib/server-only/license/license-client.ts create mode 100644 packages/lib/types/license.ts create mode 100644 packages/trpc/server/admin-router/resync-license.ts create mode 100644 packages/trpc/server/admin-router/resync-license.types.ts diff --git a/.env.example b/.env.example index 7e2b8cb34..29fffa4e9 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,6 @@ +# The license key to enable enterprise features for self hosters +NEXT_PRIVATE_DOCUMENSO_LICENSE_KEY= + # [[AUTH]] NEXTAUTH_SECRET="secret" diff --git a/.gitignore b/.gitignore index e85bd9780..961230226 100644 --- a/.gitignore +++ b/.gitignore @@ -63,3 +63,7 @@ CLAUDE.md # scripts scripts/output* + +# license +.documenso-license.json +.documenso-license-backup.json diff --git a/apps/documentation/pages/users/licenses/enterprise-edition.mdx b/apps/documentation/pages/users/licenses/enterprise-edition.mdx index b1f6a77b2..76ed9a63b 100644 --- a/apps/documentation/pages/users/licenses/enterprise-edition.mdx +++ b/apps/documentation/pages/users/licenses/enterprise-edition.mdx @@ -7,20 +7,51 @@ import { Callout } from 'nextra/components'; # Enterprise Edition -The Documenso Enterprise Edition is our license for self-hosters that need the full range of support and compliance. Everything in the EE folder and all features listed [here](https://github.com/documenso/documenso/blob/main/packages/ee/FEATURES) can be used after acquiring a paid license. - -## Includes - -- Self-Host Documenso in any context. -- Premium Support via Slack, Discord and Email. -- Flexible Licensing (e.g. MIT) for deeper custom integration (if needed). -- Access to all Enterprise-grade compliance and administration features. - -## Limitations - -The Enterprise Edition currently has no limitations except custom contract terms. - The Enterprise Edition requires a paid subscription. [Contact us for a quote](https://documen.so/enterprise). + +The Documenso Enterprise Edition is our license for self-hosters that need the full range of support and compliance. + +The following features are included in the Enterprise Edition: + +{/* Keep this synced with the packages/ee/FEATURES file */} + +- The Stripe Billing Module +- Organisation Authentication Portal +- Document Action Reauthentication (Passkeys and 2FA) +- 21 CFR +- Email domains +- Embed authoring +- Embed authoring white label + +In addition, you will receive: + +- Premium Support via Slack, Discord and Email. +- Flexible Licensing (e.g. MIT) for deeper custom integration (if needed). +- Access to Enterprise-grade compliance and administration features. +- Permission to self-Host Documenso in any context. + +The Enterprise Edition currently has no limitations except custom contract terms. + +## Getting a License + +To acquire an Enterprise Edition license, please [contact our sales team](https://documen.so/enterprise) for a quote. Our team will work with you to understand your requirements and provide a license that fits your needs. + +## Using Your License + +Once you have acquired an Enterprise Edition license: + +1. Access your license key at [license.documenso.com](https://license.documenso.com) +2. Set the `NEXT_PRIVATE_DOCUMENSO_LICENSE_KEY` environment variable in your Documenso instance with your license key + +```bash +NEXT_PRIVATE_DOCUMENSO_LICENSE_KEY="your-license-key-here" +``` + +3. You can verify your license status in the Admin Panel under the Stats section. + +![Admin License Status](/images/admin-license-status.webp) + +Your license will be verified on startup and periodically to ensure continued access to Enterprise features. diff --git a/apps/documentation/public/images/admin-license-status.webp b/apps/documentation/public/images/admin-license-status.webp new file mode 100644 index 0000000000000000000000000000000000000000..2dad3b2daea215589d4b0488646a58bff140d242 GIT binary patch literal 76524 zcmeF1 z63jR8RQM?BGOKG@MO7QjPOR*XuvAn1RM9}?;qeJ#8WxfXG0)&e!(F%Suk4(h7Wmh@ z(nqqF<=T{NB1rXpHgf(u&aFBvHa2VDp>&%ayAc8L@2aBN+|UIObMDvZ@pCn|TFKUE zQd?@wV+J)*DAhf7^>557&rI-yf4kK-Crj`3`9RQBXEMHv`}*&`Sb0-9mbr*>x5(iB zjGP}UVhAz1cjp-=go5YBdz097J#mZCu=;&8l0tZgnHAi{?1v|Kq*C$FY14Le#62Pi z(9V}|dRab}4XcTf>|nz0=%DMs$A4wkF6%A3@t|&2_Q~_J@M}0kGmTp_jRIh&p)7G? z#bDkUDr6@RN!;VWL7YC)Oyki^gM8Rr+}oqhnZaO!B^EOtgg3rl;^qd!XW)jscQb3y zct=gzu2?4_8}!*v&66<)-X?&!>wJlW6Y~1~TA|P#$d>%XeAWKi4{^* zXwYdqyP!yT#@82PC}GQvFk;iZ$CGcsKFMX|O)tzFYLRC9zPp`p2>Oe2qr-j3D&-ts zSJiU$eE2>J?ms4JKR#)Gea0#np!!Mw=OBx8m}XiH3Jx*<_oeR92${7UX6R4Yg?8Ef zb@McCtu$`53cP)JAA^ZGui8tDAE6~7PThgmt{U(QYlLyA;mGJ-6>&raMZ!4$$hne|I7v1=E z7pkevqB$m&F|Qg9^wJqM)2mceKKk5_e3vrnXwlr(t}9>#et5p)U**T+pnYvuzIVpOJg4GcCUbgd=9$c`a@Ykav9QHhR=vtD0V4QOVCDgN506zwAsH0>GzIY2GnXf zl+ge{jr$Vz1$cVREd{@t6&QbCc}OSuJ3p%=#}WG2+=kW1i#SVy_c1OX88_nuGQ$M{ zj`MyT1y1Vod;GFOb0k~!9!@$UJDQ`o@A13XX^W9nlD9+>bVWhENFGmh&=SHuXFgs4 zo#9d0kR$LFE_{#*Q#jy*6p<&F5Nail@$|w+t7=L6*Z;YXw-;4_lvP9lMh&MAVoCS# z^!=i3NgmSjy3kC-N1M_Kr0-C3;_0VP`}O-3^=FHHqt3-!`NfiB5zL+%KjV>wGe8QM?&j^=`8uTfbJ3iMRI>tby`P+r&nSMG{RFaHe zc+A_epjgiMEIN_cywFMDDJZ_>r#ttE`XH?BP8OeA2xsjj`cn^src%h>7 zhplkVUqkOE1Bkw)*8cqu>YY?u4QG5vM2<&ogFGX90s@|t z^lw$|Hn7YhStM}Jf^d{m6C3=AZJh*wA@=CQ5#GcBb0;a$yz;vF2E$;|P~h!957mWJ z%l`Wo#ZvXjB(M@KFyFeQy38>n7fE_FBgkI+;dK1VBwsyz5+Xa~T#*)P@aP4*Fsqs$J zouXDrcS$o%De!;!vMrlFx<019&8TsC&Z5#Q)*Rh=yxG=fv?l19hi~g%`QIExCc8<0 z?A32CMUitG{kTUU_LPAvibEoQ#g-`jl@o$J%C5@E+*3PCTGV{TT~!Uxt*sTDUN<9A zQID^Wx&0N-uE+Uc%2vZBT2vcH%c?|wwH5BHo6Zb#bpA$d>)sf}U|#XUj~McGdA`7$ zencROvT_~aRhZ%QwRk) z@bUp1*K;(c=Cke&5S(a1OMQcRyw-iBxdaK3@C2>$G?EVC0-mC_)kMouk?2qHH}SwI z;!%2Ez5JCy*ZJ6Qyx-pR1!0(7QY{7HH>QtA;m}$t`zb@7SvDJ*8}>nE}NV9@6$iFy(u#l#Pf2mFKwy&aFIB|RH0o8xF_kojRU&Ts+GqW#Wb zbbHe?nVoWcr!GU=L=|Y9XrLZ^cZ|Skm`@d7WdsV2=4oPfU7)l!WPVPUp8-aMY`ZP3lZ$dQX=S79%3Mk^1fQ7y9 z_s>Lr%Pcl$K9M&RG+Nz{!66jD5qOucd zRQj3N!ywDkOAWQsnb|MYRCrTYsG(%fsX%xQQr;Ggj<~%%dPTKvj~?}A`(Lj`-@_jn z6rv_-g?axh$R$$?a4vusRMe^i|4ZFUCt|@aEVi6~}91cUN(#ufcI3zIsqD^vh7lr`M zk8Fvu;J1uZ4@yVzfuY|NACsKJzJ({{WJyYu*UeBdd}1tj>XCrM>WK+~-ZdH6ygc2w zJ3y6sXtegizk6n_Hi3ak9t3dEipB?PVdVh{P^n}{&!DP04L<-tS`H|u+lj03bZvAh ztHr%&l;`_djn>F}vF~8=#2o5gTCHoBnF@Zw$HQV#kvYHwkX=6-g=%;8?cXuxzSrn5 z7XFMk%A|xcE(^CPr};0-`WOCf0pBD@PgDhVyA6E#pw>_^(oQuX*|=-Q^ksw(4m*CN z&VSq=bj&B@DZ~!zr=&`^5%;f^c4WJ*G&7gV_{k(lraD9w>yX`S>%`y*9x{^5gToxi z{37p>#Cb=%fDtviLXjP`@f7w6Sma>L#=@rX=JYgt`2(>`Cv(N4T%NZTWbF*`%w-xl zqE3mcc&{Y1?xl4l&&?{>#3M$cK4;L{B7evT7n{X$)cfKxJ8i!t7|FyMh87cJfEy}& z4{1*KU`#sAlqoxDq|6TVRc@)>3Cz>S&Y@3?Aesp=&x5Mmds0Hs&k&nPOhM!|vmP}u z87{|2Mg_o$P6Ck2U{hE8CO-wCUhqTUE8Z)DkNC4o^5HA4?La z`$5LjCO}lYV9~6 zBLeujYX(?=pwut;xSvY2F1LQvEioJPAzmHK_c+cC-5#x(A|F=52T z@3@G=$mu%T6s34t1#2zKeKVgJt3~BbKL#?v}oh&%f zS)zn5yhj7RX#HIbzYX#Yo1?5NJXjol7=C?3yxjd}FvasbZnQMbdeW<4+zlfhZMp{< zgrXALNkb=UV=ZE~R`yZUxeAWJv`-4C;tBkhKZxAK^8Z=r4l`FF1C8E&I)gbJ(UWqPnGWO z)-cD*DR+Bx42dP2OAV1#RqjQu(nUxrJuDhM(ibb*!ZV4jdT)yQK4&pu7+vqrBPpRA zZGG{5%KG;EYR|>o>Uik*>GHDg{_UIWe5XYx(Emm?|M&ZU3jCh}|EIwJR|TLOFL|@x z{N7jO&z%jDR$M7Rw>~*nx*3kR+d9UKW5OpfS1k-qNTyc?jf-(zOG`cmV7ztV>nH`5 zL85Z?bQc^yXl1Py^Z~jP*Uli_+D7W^>$&vls3=?jn$hr?vwMZ% zXU1$1H`q}Jv0>9tFi~-(KAq#^h_e~W@bjbPyY-Ic^SYjMNiKmQ_aP72O7M)flwmzu z2Rsz5D$2>tXwo%8zHRbNtarrCJ>oXN8(m|Y89mC?h5z3Fz|Lc zbia!d>Xvk_bMzJ~p?P=c<+{@4pRC)uh^JC?45|mrvjGSrH75ZHG6H@lAoXcK;66^H zf&ZJtt|!XQ36dwcLx1xvj;-tWJfX}oTC$%&sV~mb`qAgVL=n4B@~SmI~Kq}8N*sa3?H`st=Jwco)`WV82~m;T7O~tE??4|KvcA@v}1csm;xL3Il9u^TX*Ccrqd;d zwpC>p;0iYhNThIfE_SmR%Pc385^=j*=0T+BL=m_s_RrC9*vCd=_18rrFZl8VoM=XC zc(e#-nICw%$q7^HQ>8R4GP>^`2VAon&bn8*8`@qbN%XGKNV1ew&et6ZHNycx;%1OV zn1jk}ZbFNIlWIK$NPEq(x=`ELkkt~ zVqneau;ajGNSovPVQ$DEK(LcjVM{=?Sp{*o6fP!b=I9Mia)xmOxR%8`c3o8DZ`lbb zdZiAOE=cz^51ZKX3s>+tc}GSvu_<4u9lWZ$Gg9E?pCrJmd!v=BvC;w)#p>kHWc0Lp7avH@LG zt)h5@U{&lbM;L>ZD)0h1C{O-8#}D<2VbE1J{kAS!*vC^rDy0!gmr8n4S9Rt8h658x zzfFA;_=ZgolgD(RXDq(Q$*4}$!kb#O9Ujn-&tVMME zhOGKi4xEK>%QLA!LW9BCBxeVjXHFD=&V(?-Ekci-n7K&)quTY^FpV;q3<95my}HCl znLh}4SL8W5{~qqWr*61kP(`_p>jEcV?h8G{zad-xIexSgy0_HU%wnv5*soEL|J%=+ z44WX+;9=5gZXCAkav0`T!{+j7?z#SKXBXPzKrA*f?TS0to7Rrg&53d3$D!*wTY)a*T)p>bg*f3>8SJ$F~ z=~>SO?)1UXvpk8IZ@R3)2%ZTxVR9M-wZ(7TkGIzzl+w0xt$-Ij03bY5DS?<`((k>V zD;p|r?k^*H*yR_Wo)KQ)bw_WAjpVgd&7rECa3u!TUbTZwJQGR0pyeW-jnu!RQ0tDW zqi60tSO3Iwsg$$&HKSMCw-=YRy3emnt-JHNg3hu$MDCUrWuwNgdJC;@i^s>PJJ;V9 zhOg7#`6RC#ho9H|Cpz}#o{kcxbNP1r9#UWADK5#ycUcdLrwHPgef|?nDLZ0`gwHAf zwj$u?_pZ#blrR|3#UuWD<@o-00I`hO@5ws=yRA6~JuL9$oN%v#&9?*DNuqA{X4R8+?Jf+lhxQro+Lbaf%9r2Ukj$ zC-H0diF8arw+f7dxC->+&o=RAZKbE*vU(A!eycCMxAV;=ZkxL(sv-7~{*Sn7=a2}+ zl-t&bzO19OYvKhi9*+&#Mngj|ecy;56sMsck+^wuzw@>4qm#{mvdAyP)$Px&G~HO-mF}`xf%};Pb2Y%Ecee8PabZ_F1Ie?{KguvG0@F%PZuUOpEE}0FCvDMey&E7}Coj zir}m#G#`u+e_N}A!O8dEUWn^&L$O(HvTb!V5iLpK=2XiMNUoAIXZg_KBCrpPIMg_sT+- zuf_KwcU6A^TsPiNq|4Mv*iKF#@-ZX!pZ7VdhI+jtrgP;mPN{UC}GWBT7U^Kc#J$NAmb@^a`Xp_0PWgF4LJ-ha2o>TiC@ zEnk+4^c=S<-QJLWQ;OKywGP>kW5Zl^5Ee6CJXM+_3~}n>GEtWL9!2mo?M&s$`trq1 zIO3kwT&vtX3i9>qlEJqi@e%n1Vk`x1zt1tvdrm}j{EHyBewV&9_{6=(gN(Uml1tPU zX-OKsJvk6iMs!lFB!@ZZ3k~099)1H`#!MA{;>Woi?&&$dRk|H|1KTiJ-~L}C9Db2E zm47#twR{O@yYcF_D%kAaq-Ha%0Sn%tAI@_yl2|K;F#vL4+^fX~c8bTkWlhW5b!(mn zRqSu9(&%ckB9HlFJJDZZ{^>~ z753HD;{q~Jv1$C+r#OaS+;<1B8}1nV03}_pVtlV1)2b5~=i`@-Epyn4h`vK)_l%*^ z&y=GQ!qc9V>%#A(cgz9L^=y|6!h-elJqHYf+6}fLzI$VNdiYPOPNfRkayf}}>sf?( zA7HC|PDU@Ri%Bik_i$)}kC%JG+ekCfyRD09w-pxGY<~qWR9<#+`{BPV#Cyg;r#Pu1 zPadMz^|m1@PgS~ze+Pdo(^{=aW&tlhV&m%Jp?el9Ehq4GUc)VKR|?Ror`&*Pyvr`Ko0ktLvK<_zd?d>M+qfrq`#7~}5uq3Wq#K{y@&WLTQg_aVTVZWWI~K{G53auhjiWx64#y*E zq`znZE0CNj*dyls^82RSe8ifjKM~>nG09HoJ0)*9GpBqit zPY^`JZdh+a#GXTy{fAITX za1W5kYx1wH5{(NJGkud+nIuWYs(x70lAcW)Z9{H#i0xK6if>`OtdYoBFHyt0jh% z7MP@Jq;B2>Z@UWgGXYZUg)zjElbF*Ykh|mgtBrtZ?Jp{k>;@f1Zrte_>=NeIh*cNn zso6@mLbY9+OXe5fNF)d6U{BJjJ7+a)dM}Z)+V~^9{^B_T(E3+&8H9eWX{l9_WRt2! zIK>hL3f-y=6h@84vxaI{7EAs}DSgm!fQ7@7hvGBsSX+5ApkE)~sbd;Q({kyJX^qgl(@YNI?u79vT7SYz%x+mdBKVlJ8bEi>D1r747A_S1lv*Q}EN*9H+Q8lN zV!*5$Hn3y5l`#-ItF>_7Jp!&S>i`*8d+{Zx+RW3RICblDe`9c)_Gq~Bi;~L9U9TA^C`anW{O_@u zxZ1b#4f&qWB7cso9o5uUlQ?KQ!UcM58N@xaNcoCtML@;sTu!4Vn#M_`kz#RfR=@i< z$Nb|sCkk+;>2P$B0lxSRTqn9kBsG<*936Hm_D@#l!=LHnYHGOlONnsQ1V=`Sjq%$}HeMcg{A`uOmQAS&PqHl?iP`I`9qJqW8 z#qjju>%Y4ND&;{=vAd1_#3gv5*m5tYJygJAR3^VxMR%jmX+W4s-POzEc%?o!VA-+Xp(5N>o1ab%F(9?s@_P~(Sg2C{gTzWTY z^=bK#vy0@Z9Dj-M9YHVwfBgs>^JSQY?VF`u^B^`wiUY1U^!t6+Uee{xp=7)6BTvog zOg*cH)sh8+5Zd&>_ouE+r;_kn63b*VS8QiXohFWb9^LD=1A#ae#wT?vTU9ij87Zp( z^nCSi-sS717%zHcNRL5TUmCcAl%XD8rJYYBvpj_#O|2`Tg_vu9GAuUUUyv#QtQ$)4 z`dPY^r(8uUbx9K%w?%eKEA_|eF{A(m?)9SPWU0Y&d-0r+CQdbe3y!4mGP!6|Z;Zw# zVJgj}Np6%AkMR0nx+@(_v|BN=JR8#ej+{#zy_Rh2sQnuh`2%mJn4mqu*sN&@Xx{I#?FKDKL=Eo1b)fgzbzF~aKCEo{Cj`xoq3 zx-7DOclN7`5^@e%^!u~Q=p5@ZKMjN|(fPQ2X;8txP;mLllAQxwn8(j>w9F36EB<(f zJleMH{eEfxkqQDn`s;q$?aRq%VV`@S`r?iaKZ>_OK8oYhG=HA!s`*kzq95aLzwfhs zm4O=;^tAND?boVR9K=(+>|H)8{ddQ;g8IL8#XQe7Tk6EDNda^~E7zG%5dg8ofkQ+X z(7*pNlTxn!o6lMEMQ4XdoK}fu0P04}4 z_2}lr0!Q2p0Y8vq8ASz7P0^N1b)}|uLqCOQj~-PLsIjUOW*5rK+JB6>4zNx@jcbo( zR$SbY0!8Y-rpg_fn6fr~R&YD-_#e%)$}kP7H<&kYnB#oSLw(zGeYVR-b{rb`MMc~e zVpv8*MxBvQ&ZjG0Po_VV+6$gHsF-wdN)yY6p^!!Q?{iYGM|L{L?niWF&L>*9TmR9U zcKSO_ma`?KPG?N<=IyM`u&-&_h?E~XbDrkc{l@AS(UptSJ5m7H_@|fI$m6`%n>O@L2^q7@Bs1&CN;gVNBEIrsa?Re96 z;(=cdBOkhY`{J>B?EzC;TCiBLo7atebUE7%X4uzi5BclQHQQ*3V-}s^|NOb5cx)`_h%Dx)xgxEPpCk5I^BxQ zr*M1nX^GLH%E~Oep?myfZGZ6EXt&RHeW)kY!2PA%OIK!0t+bzAqZ707jK1JNwaC7tad!*dJOtzzzdt^1R!{h?tG={(w1>I|%y#EfS2{#|^XJ}{{u zjA&9=<6@LS9kv|qF&R(ZHIL6!houQ*3s42jA2ERO^}Yn*GQ6spU|<$bB0!Qjp>67w zDkc-13|~NBJW{bD+)5tsYAIlan(s$cgH=ry>^HG~^{8 zE6g6Nyxr8~RpyBuG4v2n#x&;~eXUV6phUY;74O7nwV}d)ylFW~i({^&qNN8!9mfZT zQ<%#0x30AJc>pj)8g$;^_3sO;24Um8WmEmdsdt&H3ko2gRJ_D~;JbaMm?L82*OAnovgL6 z-x%opDJT8=XZc;Oe!N9#Ho6*IfUf4*;F;+*@@0UFkUH05zBi)vf~_#Xu%uupde8me zeskl74^m5TslD`<^UsQydbgMgC**rIt#=+K`V;CrVu*k0kNEl0t<`_uE~Ack8`Y$R zVgf;lE6L)Uv_Eo}d~2_Nd`ULAy=m_n4u43 z3;A`@Pm|ro=0I@*ZyK|*8lp}_P&mB(uZGMOkBVyFOCqc#n~pj|x&eXDx6K|kc!130 zIh&3w?I~tN6p91>5v^_gy{2WAM65C)bv{{e=g;3{XI*o*$m$$#Rj;DqH7K8|cOAOs zW!lKdYm`iXvVJ5E0CmX8r<)Pw6ZUiifFi&&@3qrQP4NY#@YJ@+WGda&hOyDtMmtVr zU#Ku8_oa~+Ugd))i~z9H#ex|*^(Zf${qw7^SsHiOK+vUBne1+WFb((F^DJAczwo6X z^ilTJv19hiFaInv&aXIDjp?PQl#c*wH**2CVS}bS4T=e25oVhV|WJwi-Uh@zl*3O{yZUjKVP@5Sc@TP01 zc+fN03_q#`_7y(d{ND6UpvC6%S@Czk5|o1x8V@B)!v>~++iokCp_+Hs7B+@z&uH%X z=qJ#(+G5-LG>R8dtnli8GeiK1-;Ru+wRH>X9i9;MG^f2z($LP{?4OsGP^j%_Anfno z=1WN6J5CA3%=a1*zjNi)gWK&-gXz)*@G7_oe$U;tuvwW(}-Y2p1{X8Dr5v@ww?bnzK|c397G?Q;k5 zB4IBw?zxZx;cN{FxM^-M7Fi*AhL7zjvIyFfUnzBui}(s5V_;yAF&fia?%V)`PLU2+ z^Eb77u*|Lpgd5kNO}*S^HMP5)f|b$!n!vbBR?z-5VxzFzD3 zj2#&KwA^_^aq%^J^{C5&`Q&jx-`Y{${!2M*QPICO*wcR*>}CBMOn|sV_eD_vi5A0m z3DAF5AZau%FxlWOe#JR^TovWJhRcZyw4lD&{0qka{G78sy&u#%4;w(e3`kYM6%2${dOlA3x7P}Lvc!*uh=7B>0$20e{U7~JS`H5Y1Ft4K-i6mnBF}&oE?z5j zs@>|rX6!13wm=AWB~|Wwb@$~Q?Uqs{lMT(EiJ;av&yY+(uQv^G5|K(GSv?O6IfS&` zOx@Pz2eB33@+WUTf=qG6Sb5^2%tiRofY)XLPCKeV+)cK)q!hB(!{z|UJwZ2 zbbRTT=;%O|lZm)Nlv#$v54V##k!NO6m+kfa|1+I(3rl4%TIEJ` zcqsWZrQ=aID)3MD#ws>kDP0VS8d!Z%+Big4uBz^iFt{v;dZL7oyiE?^xQ zr%;f*G*)x?kCB_o>v}rxVaitFxs&UYzlV6Ao8~X(AB$dHeucjTkvx31yw>7d_*^~g z4O)hM-6~?PO^4Fq$mBd-w}BeMu4JI0mV5CEl}q8`O0+j8=_6T<{kYGobCSyt<8ilh zH_OziNfLNj&w52*31iT%U+wDojWb2Km36b_hqoQ4MpgimK<(gf@Q;5>$^Li-O%$6- zF#b@h+^4gHb*S(x-y-YSVFtZKojSn8JM@9isjCKewKM{TV~|5-P&`K*X$3Pu04wAt zY8G}QLS;@vBd=9w5x^iAZ+7m?XYcU+C!u-6Neh;uNTQsjRoAVUs_OrIG5n5zJ{?eV)fuVd zf1#3{II-;eNil#J0G?m?r)&$XaYdJ)QFqy%B5pjHb#ZdC!H@v-3(E)51OwhHjZ5vZ z@nkonpR3&xpD%9)GD`aKb8^d1q zgqKYAQeJAv(t#{Ky#5zQ;WCRJ7(O|9FRxEHE?IXSnE*{12%Wa--!V-(N!0El)r^ke z|G_QxA$x*sL|OD-*WZ<0Nq?PcAB|_)N&T6aY)_^qHYYL{p@4@b}MW>q1+(bXZ#&(>xs2lff^ReOujm+@J=U=gn$^daA*8uag_kPS7{Hz`&De z13ap-9NA@$S%lz<*N-9Dsiw=pBW9z&GLAa{ITj(f=v)%eyX{un=B7GsJQ?%}+RHa^ zbm%JB-~m0mxI*Dp*c0&kRr?L{)L-l0SZlg)0b9Yv?$|&U_gbkQmLyL(z-yNgR?FX( znB%s%y5kd}xiXgV@{?96`q1Ww#YhU~PP%&`$CqThNc~O!%;v&&AKDtbV}vPkIri>E0HDGqX|1W)^OK0=_rZ3Phhc{%#d#^l zaE<&$;Z}5&+SDDZYSFFH7kNd89;v_;NDrDYCsk7ACw*=A!zgg6Ke0 zV1iyWn!4{5t7nz!NRa(*vlxT^YIZ%#9+W{}-`v=kTt&J}$7L zT__P4=(h3XCgUxm&yR~PzL*7#Ozk04azHkb>Df~ek+saVRGwE{jXnZk+6o3_A4|uz zAsW3!A&mk75$h{9PlPDg(c-)|z;hV`5MYvRA1wqXS;YnpJ_U2Secwo+wHY2wo;xu` zF$O1Jkz5UoXU2&cAQzi3asZL54L~2Yp9M+6k67g(Sl$L1IsQ~1nZTt>F#v!!HKWWO zU0abPXhD#o9q)eS=cCZFL;{PF19pK?@$;VLce@wblF5P>U3)>x1vMtYPUianu<|=0 zbSdv`OUM9qci*>hA7}E@l-02e6~*eeE$ip2qi6KvhyNs{dUo5g$#Nt%{wgLk&YnnZ zM|kFl&#ZrmjFYR4SOfux+l)^o?I}0ntHt9-!d+-SwLKc&WaR2$aI3u@ljM;&E_-L^ z+h%wR3ow|)J?sil%}T-YA1wZq1BxQUX459%nA6ncA9ow^(U@qeLi0Q81sHqJ9#p4FaMewur`R0P0Z)vS2(q7$(cUR^i20+n`46F?8 zuN~`0CYy5_aET|tYKugB69WNlUkszT#3Zr=^@%nI*(*OJL6BC-f6F+=uk|t>9WY@t2Ix@VA*H9t|rq+sk4ZTO$LwtHc8| zY|w{CSW6+au=JP({(G ze<(jGu=6R!Yzd%Tdt$`|Q1O#q#LpWO1KBdkIrfAA5QYqCRAsHRI8bVLSQe3c4 z?X<74V|@>Rkb3|Wwa;c+VUvpDa)PF%NMr_Jf?b<79_{-zk$bj#)m;0zD1+$UTb~+-}b9>YN zCJ9J+)vjRWpFFh?;)AyCj_4Z2ViDs~vSP}^l^fYFLjhr(W(xs`$?>#byp805jop*2 z@g9=Z1=)Q0jlmPO1I}tm7)Q#+mZT%xuYI?tYY%r%L~9$n*j`*NZr}hGmbgXzgOTU- zzyVwyNaZ+wP2=6NTX+2z95G*hu}ujja`CV4A0r0-e-)CK%lDJKhn@T zxm((wX-!8H6}gke*DHj}611zn@(t4TO*y8VsUl1ak^`L|WHC0Gzq`6+*B~dictik|zCHM6q*2?C%fBM{Wwk-rJ{0kR1|t55ITy5?9@c|d?bNW=t%SM%+h zWMHD+aCNyPajJ6=@;Ge7@C)262x2QWru>4u91;J*f3IltK@rE)lHA3CDmA3h-0kJ&K~A{~;+qC#I!NF1Rd>_&4jcuds3!kW6rp zEWdSWY=1Pnja;8|OCBC%f4u0iA1asM-S64WdWKX~<4kiD|FBR7fHVKTMAxRQ)8_DP zLuqrcXVX$@ood-Q%D%Kmt2-SIBkHF5&odhm>deyV1bBJZ$h)F~}mU&T-D{Y_xpA zM&_t!#$Upije&lpYovjZu%9PcOc^^(J6mi#IYfZ&560qygT3Hz?CzVlDA~VN$rc@Z<%YykE*G#88YX#y zQi2JGYqCEl004tS^HrupsI8>N(z;{abxHv04&`C_ zv~YtI_OSKRQfhC}gK=dxZ@Q zum1jE0c4tf#iFNrx3>@JK+6ibdgznxkd%BT;9xa)DZId^^zK6}`OJm#{e_PpnHRt- z7ZRTb=6wQes10fMPmzUfKMpxkT5~;S#U6|;%Ds_nTuOe=k->m1a*d^?f}R(h)wj>L z$72K7?VR|{2Jp0B%s21PU70Keuv3$zh2`AxsrAWivv!!I${6||>e2)5Ve2C_P@KS@ z*4Pr4e@fJ*=yvw`MP8gtf5$EW_)d?SMUi1qIQy4V2@dcP;KQ~X5lJ>Bf^W(=r+N8x zSqF{7S+-k)C_NoO0J#0H_T>k+J0}sh^=d@49*<-j+ZMDq`pD7g_QJW_>xFS${XE2( zVW}*lDgAuV>O-3b<&E$DH4<>_*F#2S}Ep$Hf4fy02$%C!ULH}m)c*xNw4&j=ih4#u*p&ZW8 z$lz!2cikuc(h@9pL4%X(18v(D0O*`DW^(I{V>sceX(5P}^_8&2YU@VI+ZLV7Y*^O5 zGcq*^*r|xHCfxBOr;Qj^2TZf9`mb`}=VG0;)PCr1n7xRloAyUNT^7$NO~OMfMUxe? zJp28o^;pNfgTZ7$0uB(vtoO`W157_GYD1IDsPgMy$Yz#$yM~@DPy{;JCL_gf^wrcDL zfY_9KHttQPEzS^-H0ndaq>kYP)tZeKb7)E$dY2K4pRCQ>tx|x^)FEfSduMu5 zx;LBx_+&KwFyFrh+eb*0WA#BZV_fwClRlIF?_C(fy!MB8hi68~6o7l>V_zKraj%Vd zwI+N~p9k8=V!)0?SOaX(y0YK=`DktPbSrsdY-`h5@xZh?vHF(~$%$oqJXuP>&)21~ z7XdjCp-$fqA&OSd6py>Iz5aIn_t^W{J8PYs50eDoBw0NZ$|cOZfb7Y*8@m4N0j{>B zX8#<}rq`Qaj(_w?}rYi&H7%By}I3swmWDvY1WWt(BjN z-E2Gm*me9idanpMLeR9a<-zL>D=P?~jv=x9URu*4P-K9(MAElDq5ir4CBEL@AAd6n zazR5ddu6v%W#3G4Q5kLumtz6E+|4Dj9&{g!JPRC6Z!E zyk#Q`64n)l7#ktR_R8bdL4P6vfR4;^IOfQyAC|B&yq&JCzJChMLJOi+@BZZMs;-o*lY9HM^wl!D{YWSx$M zK4%o~^LJmiUY`c5ed!4%|9>ofbwCu~_x4aCEhrMw3K9a6(x7yg1Nq4h!NG-5*O26~@{@(v)b|>!KJm);md2U@9+FQ`nC;7<*EWh+<5?^0f(p}$i zSqm5(qlqP>%Znb$@vb^>4Zz@@CvT$s2?Q#Bl<hV=2>AZqVfVT8zXeEB3>Q4%Ndmh0 zhqtdV`s`97iCLcn68DmR*pg!~k9KzYEEvQ5!M@s@WgPRhYfP}uk|i9ET{|C8&rxmx zc%O{B?`1WWDx*8s`}%yW1w;5bK=>aa2$|G^TDVt`j1d?(0sTx7LSrbW1zIobl*r-c z_MycT6VTq}RIiIYbt>e*iOmKq_thHIu_8sO)n5Ybt4=~=Xf6L)cto3z_DF%F)9b=a zHSVh>ujfO4V|*pbrf*>g*nT6+Sb;MZIvw%%9{Row3)qT2CmfrqXl!HNR$sYmkp&x= zg&dM#9W9B>8$iC`f0E4KMWzI^G|V&~b4e<4+@b1Y(Zl+lq>5E$lGwp?$h+C9eqRpp z7mUhpL+U7>Vpg1RNZ4)ZOL4wVg}$fgM5E;83U0P<7Ns`WX+aTT;&>n@$q(;|aq)Ft z+>tio;CsTjZnx&{@B8ByA)Sbsf47= zHs;x8{`8u2dYc-ls{~YJ{c=1>c797D!bY!~-D>R*U-5u&^;6!#C!;@PB+xFebR;=# zb<=J31W1`B(36@O&1$Wtz^a;?EK(=ood_|KM%T|V>p5N$klREvTmL$g0~brd+MqkE z0Dw>C8JfLdeqr+HS;%N=zfJD>S)odaMZU9UXMX<7^v%zgOf{43 z>a)hQu-i-K(Rg0jreOaXEj?<1O(y!>>q%-gT7(XY(tWzOB*(vbk5Q+9m9 zXW5s0IdOR98ZpqZ+>~&`oE6{c4t6nCr)`7m^1^TPr+fFF^3nCqvf_&7-in9RG>#8m z=xYPicW=};Gh1pJ0*XiQbf-`Hfal+QgpXrAhs~jFJGn%hJHOz$X`du2HA?Li{WA8O5d0ih?!22K;cWT_oJm z04-jA^;l0&jq-+*Q)|l3Dg{vS8)E>1`tRT!Xn9PRx*aLaG&{UtGzM;S zLFWn0sU>OYFWG!WB912nh6JC15qe%!)bkMQ5}0e~#%Dw`Sim{A+| z<-cUo;5@V!B?_NJBsSYVsuEp4jv=w^eQY0ZAi?^<fu1wQG3*wU7yaxK4e7E4pdS;0s5{>UeXb(%^ zI2Si<&NC~(0T+e%>au;;z_cl1Xwk=c(GT^9uub3bABC;c=p8-B9qV3mvc;!peqF|(Wp3EzB!%>e0I??Q+&Gn`2kp4V86RpvBL(=JVgxi^6Rd4ZafTxNBaax z%qBN#H)VWk8IU<@whL*!7O4H1Lt_&tV7>ZRMU+<*75Gw~ZIPC{!a?z^Oi$Zk_`ww3dhz z>$ec<#d7wBk##Y%wRh;p+jD<6$$t)Sf=W_BKG^|Lr|gI@0%+p_skMc+|t^M#Hq7vhh9nWFeLZ!acv+plZg3TWls)-1@D<_0PjSTN3&d zUFbjeZ-3ZM&I0bqj1SiM_Py=BNQ50(?9l?F_*qSmnbgfY_Q90__l?r6J_N5vQYGR# zW&Tn;E6mf8ICl~oewMn-9x)_mg8}@ifmc|Pi7IQe|9V7MY1d#q#lu*4PDw$>$88P- zzM=4Ol&)G7BL8H^)QSI-^_lR40g+obEN>`kC*uS$Ck(dS<9>fq{lvB=} z3nydB{7zAvT<~P$zo05X7#`;{?HkFb-8`ljPXR9cGd>+a0NM9G`?SP zn*U)+&BEwLeBnE(p^WIYUMWmaR-RebMZWzpygF_1VwX~{&ngw*dAQ3?@&G=~jb1K^ zo_UG9YXyyGMFT;W#$&f~v2w&E5#3p}dEbBIy+u0zlAPknwVuYj*a{bS%V8hzy8Kex zJ6#NJ?->%(`=>}cRgI%1I_Wa^n6t^TtSk?BOXP(5*xkrqry{_C%%~gjUphkQB{<~= z?$7!E@vQYkiF|v2->~>wNhFb{7KJ&-g*%P;eVaiB{Bb#J$bAtboCh_ok-X(`J218o z_-90Rd&e&QVo_yOeQgl!l}2FY&NNiyA~h+jqXxBZVT&u^&fy~tDeGd{Efg1jCHGto zZ@%&L?azm^J4l4LRM4LqJMW5IB(wRZmK6vEe___kIyJ6#{M49-TFZ8ZnWraqrDXHBh+l=)Fe91 zR*oClukSNobMdotZz59b=B^vf2$!77hQ)|=n!fBtKtesGcE8oBOraIpx1mdc1XT*I z!7r^eeO@S$yoJ#0LCqUu(ekKxH{#7zlT=@Izppx@VufpiYWlLXBX8(%s7-E;#Kq^y zKIs!%!MQ3qcmNgkqW7cIadSNrO<)FB`2i>2M(vLVbnAqb`@;wM?0K2ON)}-3_&%Q; zJ3zFs7v6dZRMF>u7%KfPYhKIcu9dTqS~dT}=e3^|P*~ZLe|HMPAdkJpfIwVzv<7g- zV$gKRG$o6bz>JHgH+Ml5mNmFFe#x(`@I*~9#WcG)+^2I>Nj_;*(jns>bEWA4zXQ2> z5b$uArIkUpD-ms;0?(8-5IiC<5>FgASsG-JdK%H$Wb~YVYuN_L*R{tASr;_5<1F@o( zO7|?>gq>FY!#KKgoVjdWs-|clZyTGOO!xi{A^RWuk2B!)gs_Q1_l@k{k;)H^RKsN< z8a13{@+X!hX)s$C)EOh#DGgr*JzW-pXM&w_{f?mQE$3rDu!DL%QO&x%z7AMED6p)c z{Ox%!%P{?uvXWfORqzPSC}1D@Lfbb?gh9lCKg1j zkqL_;xn7y`-m?w8QJp4+*)-doFA}$`zIQ-E;mGqL_o>N6p?1hLY2AprRkND`Xj;=v zX+uDQoL=1t!t3#hMAdds!y3$`L1|k9G|n2<5I8f7LVp_Y95aLI!d^ON;*C|Fy!Bod z!dGjbk729GT0=BM+O{;fxl^2e2kWjXwjEM17Bx5Nh9)G$#uQi=gM6G?H+T+!2VmW7 zB!Xi;^8}%nJ4=c7bDhcyW#F%}1zwfwP3S;9MD2+pPeD_+cJM?Qcv~s~j+s$)tsUM? zSBqa4g;vubvMvJ<*~I|}vXir*wv*I0-E9Nr^7qQ(5qN#ss$iAj3z2r%KG;*ROBerF z$sMp}H{K4XXshas!TDkQh`N{o&0}V8>XI8>L!{1tk8ZT3X``Z7G9LJC|K|tTzN_>^ z&ZyZAK5*#)`{Uta4@CBbb;L24R%AzQFi~cz1`ZIhpa|l>Xb#jnX%Os;W5oDk2Sm30 zN9VK~_&s0TWo5b{R-D(p3n36IQ5dcn%KJ89tApL&-ZHgL<#C927(~_!%^{PqCUZ*Q zU9%g9EnY|$#A-Q4(M$L<%3wleE2wUS5hnVsx*dYd)jpmQIYKn#OpEa0{O{WQFLfi` zw&Pvkj|R~`G5vpGEi+pvq4%MswPs-s)4>2o)`U;!f~dmU=s;%wJm^yF8@+ z4G+k$0meFF%i@!u%R)Opj4yr=C5Fes9bD;ScKsJJNyEQOQGB^ivTL6Q0v-ROfbjJE zykxnyD|r>De%MG9;o?i>ysn9y)1qKV5{sV(A0j(AS@t_2lB)bD+)vB|!sOgWorLel zyx%fZE~s67ogbZY$$jOF!qhQ0UVS>Lc|dH68Dfk%y76d_Pudi!|H#-OlYB4w0>2%8 zam=lP5T_Os?0SVk3ILt!Z@b8x6i5XDz&0#SIY|v)y=u~hBa*XDMb4YA!i#3&`03iy z?5r!g>H>}8UoIOxM)==6w>{HxK3aTTrzYEf9}fDfPf5l4>h(DzS?Wd}@HXYw|M7&5 zu9k-wn>6TQ8!(IeayC2$a=ygjTI_5hOhi8I7f}IVL^a@D%si5v00R)QeKC$$coF;B zSjzOJMN-F7QwpEv$B)w6ubr7R91k53+b!<2jIj%(Kc=b~4vvU66Qts+94lD=nzj_b z6TM@}bxVwg3L?;QT=+Qi%jC?{$(&^ho7~9PD}Elrq+qF5&_-Sa1_Hi+`unfKE;^ou*De+41K*wd!u8H!3x|J9cXSrOLrv8?;H zs{WeOr<#T(leMrS>WKe}+;V>MN&2LoBMU9_j$zcbg2C#h=s|y_Z-Ni5GxMaP);&w_ z@FS~4AEnKZ(FZoQC$S(Y0o6~)-mGLWIUe)u5^v7fIis!j%H=iS#f0_vulc{KxW7K4 z3t0}PHMJrA(CbrAlvenAf++D<(lykTAbV4ZL&Tb_W~LRo#Fed|P54wbo5gZN>f$9s z*u{XylB(mT0H1c~Puo*O!}P&de5bB-es#b;_hVBlE+z=pzCe_f>9FHF*(R0EJdZVL zU@(6u3Q}z-ScB-fQ}ekG<6U~zyu_=n4Owwnt$DYZ^ZD_GTSn&O=z5-NZh7tN%Mu9; z1E{zwd+OuZP3Ie07ZOEP#8*i~RSWiVWf-Umf zWDEJLDV{VZctd(!~_S;`Q10lg#jUA&VAtUL@@;*7klbq-lp(u<^ zxp9x$hvpA;eGxp4)W_$z%)Q#L4ytBZ1}=TT$VDg{GqIq;^~9V`d~xT+py#`2+b?4A z3&~Cp#-H~pq}vHsIAL}{%MRjBs;-P*%hzu$vo>wyxldh0)WF2|4Z4)U$m6U){mL zbxgDMwN(EeRT1neziXEl(Cgd9pL7AsRXwxq6{J<=h04&9>y8@{5%vagx=fMpvwu|+ z8Vq7Sk_#jR&3~KoZ7zmj0k*o+?S*hE6kU6RZEKVCBY@J=i(%&Pd>?DEqk;G^5wQBn zFxVH4z}}bpjq&sBiMZpf(*bDi@wMl`6V}B!=fglW-9Dcg$O(~~v-{@si9r@6%o=Y+ zNo}Rb*(Dr4l1y8p|mlX+e1f*2O2kolgAi|@Oqz~`r+u4m!wl<2b1 zRIEnZHo!NCnM!?h$mSy2P3?(o>75Tdq=}HT`}QW~XXAADDaQ8JO#eC< zievS9`K;!0f~h|&|Dg&U?f9^ABCaPEc5<~t4}w1P#fWCN?RhIJ*0|pDqexTmE+WDB zEbJN%xnTh+le|LSRKa;V$~Ft@C2QB`O%nhWc0^vYomQ9UN~`uxvT#zV=J}k|tBk{B z^^({WLEH*%tS7yO1kg);{zm1RJ->#Ts_SvhoHZV0!>7UFyD~cgHUBjj6`N{z+f8-t zp!2KP$P_>hj`~jO-Hp}v)fHb4X#Ge{38(Kyy8hA}m7;x{_u_Yi{g!6G@SPGNB6HMH zy?^TgHRePRqlx|z4?cQ+V%%$Tu7>)Ne(#vyQt&O9k`%rzX>1rH8Q0@G!zculeokX8 z>sMTn_b}x3bC;Hu$BBxYVhVf8FlEsZ&b6aTuN>5N$O-1qC|5%yGISD2xTM&z3g344 zRhtodZo6Epd&e$ITq9yU$r^Akq4jY<+7vr9{L3%|sV2=AMiG1cSvnBMHr$d9^Z|_e zr)w0xiX`9oYK}s+)4o1=Cq?yC_Vp+nHb5XzEcJ$FiIt`2EDI9|;}Z4sR&N0-Z%9J4 zEL`XLy0`FF`2ixU4-A>T*sOW;QTfZlrr78dUcwLaQQ05 zLQdlCcY;5YU@CyoOoD57S{<=4vm5nZuT=OLuJhqAfWThM_ z#;9)gY1({ROEcqo2r&ZnN&{PLM9ZIb?BM2JZ7cZxTFy+F*(fSYD*jxy>F@U#Xx}!w za`w^u=ZRW8ghF!d*-tZ`L(qR|w1YgSat6(5v=G>)o!7+7pJT#@kd1{rCvsplZ^QP+ z(x|V0ueAIZt#JXf%2Y4_tehWZXBdUc{sM6&M`0ndDktL`W(9EZ@L4i&L-xr$!tR>$ zzJ#_gs=65%cJ9u$eEUeZtSn?*t~J7mlNNa^JYhV|wu1nJ^}T!DNy%SupqC2Tv|kGfwI@RtD9ERcuJ+F(Qx~Qu;F@38&SK zpMCYqU+f5d6xNfz#hXyQ#3QTGsd~Xz^Cc^=Cid2SA*JO+it<-Jbi#Y-%f3-y+0ikg zVP&S4&z?9*@npKsZKUZDGmR{Y*#2h!yaoU3+I~xRwm9fV2qUYI?93RF%JI8Aq?U!DJ5{6Y(x{JzCNTyUfd;c@<8-;;C5B^S*`}`*3Zf+P7pZ%6zN+9K58!uImqXr zjQ;;|Y&$J$7g?xkGYhL^KvokKdsNk)@4}(?zlP8@ivzidcwcKS6ltj_X>YYrgq{w} z3-zxjJvX>VByPyVXL?Z3YwuoVZt3pJn@{s;8QU32OfkbYg*VM^%ung#>6YF}i{Er* zCZK9L!^vqLlbskvpSR>QT&X#vo)1cif)0c|M-~#4c&Yz=ivL+w}kGC zifjh!Sl)4~dw8NiU8YiM`bDbm%yYBxBIV8S*sV+jZQ{0M+S=!9cg2jUpxC<5BanKs z{zX@^zP#G`V~Cip_=?S?L0$NI6HN#e-m$VlNEq)k)@30xsNJ@Gp~d$+n75u{BD{Fp zMG)uXWj-8HSg86jq_%NewjVMFdI(`De6)u`=QJq$aP_rPnziy*2-=NNPn%Zb`vfeY z>MsK#)Z#gDs>J}LSMENc&nHl}O4WQ*ZBm~P+8w4*TmYA<)1KEnmWG(R;jWw-4d09j z`1F7SW!Lyp07o=fs-~$n0!h}6@8|V^_cm@?S}*&}Y}}5oj|SQ#vbjkwoBN{G$wtSn zQG{!tvgcqwS?PVlK!WG;z}Jz5!o+26=%AFQIWCbSb&uxL@2r{M!4Vr|^!$b+wECoA zK{;|Te&na#vOI?7-J1@@A~|Td2HW={p9$NqX-o}s2kD|`0V@mD#8vAp95EWw%EE@zCa_@D8z{ZjR!3cYsxG9YATu6YZ!MHh;~K> z?#+<85e`8Kwi1J9DDQuI{x!yu`wBg84r|)(Sgy)TMZSyX#4&mD7HsfMFf{*)fG0B- zIUw%z8j3yzEMH5~$C-cg`*KElmhuO9CTq#6U!$bIy znki1MM~h=@9{ghvO9EX}22FcURP#8_k>p}Mb4)*ah>v?NM)E&@+(XmG`cM!Jq=xQT zc-c_d^i`=?E&QfS+MZmqwfSw&?q~rJxEQX=zUFKqt#yk^sGkV+Av`g7mZCq7AQ3hA zTS>(yPFxeCF@6m{F^B9Z+?bIz%4k2P`El^Ek-*w*oFLWp-2~Irpnjqq(+B)4UiVR& zC*C4l3RJLvyS~j8mM7_DCS;bFQw!zFd)8F2Br^9pnmdcFbrO=)dC)JXD1XZ8r@4+P zo~~QXL;ZsMqe57ZOQuv_VjsDo!lnAtW9Wd$V%9AZ8`8JwJVYnbEvrx*!ne;mTvw5|=H=@B9mpg|wY?qMe?Q03|5=+% zIsVN+xIXF)O?JL|6g}A$+vEuq$gK?~EZDGxke+ri&&Zl}&YZXr@CGh$F9-p^@WmjH zlk17nhZD(w)T{b$Qu+Um5MJ{G%%c$DsM3CNdwaKLUZsI%E!v7O<|%nh59nl6&gfBT&Nw!AxCGIP)<*%kzTh zI9({nlgDWKl38FX>7JIX|COhDaXmQH%lL|$F{F<0>g+bbj()Pkl`;mwq^s%o5Ju$~ zpmHB&Fq-BOt4hr({nzYHw@FV_s$V1l!yG5#XC5h*(tc&oG?Vt%HwzPyW41}&^9G6V ziiH8Ca&vituZE|oK|oEl>bEEPXfO^1hkK_z3xDKV-4pTSwG5wVCk9{;e^nHM;e4~; zb0_$rB_>+TQq`zxT+N~`fwFXO{omvwIp|kUymF`mmO%|EHI+p73MPkivLbH`0=qt9$jH9AfLlH|aSPf{n4JVF)G*{0 zJ_;O_?*Ey2-s^LlR$gbuetIEGhcy<;CpY!iY4Ij?_#D1Yv8vB!VT~XY0{RF-DSSe=%r50h+!`JFrH+N8N$5Wai$Y;75Mef1Xn^ z)JmKh+z;uMFhZi~Jro*eXum21;1>c(9(Z!w~?B(SR zwUVS>gZSJdtUa41t*<@MWEr@Fit85EkXqGRiJTRkof+w(*?~~e;A)VHvH}eE_R}2=ZO8#*#+kgfqA3! z5piA5QyqkCco2+ZeyO}c5OgK7U3^&-_p=}=;fv+rI`37Zob#%`P5HmI8FuMeG8rR? zEgP2TQ0RE7r_?w&)XB)oO$x4NH?0RrO`+CrSkf{hXB!5fD)(fY9O zcSEz9)oxJeT>tg{#k=|6CSj!4tmCtYtWY-IW`{Clo#5T{UHSV`j)CTbVkPlSFlNIr zk6AX2zF$+@$6rKVEU)HQ4q6Wi&qJAF_M@qa*o$A6Qh=&cZ%`Wb{7!&tiXL7{Sl>sR zWud1%;|KF$H$LC$EPMl`(W{YVZ^Nw0ZDz!##`~$daDFCZ;9ch9ZgKGhXdEzzh8p3gU0l5@{}>Vy-n zr2H4QerLV#t9bz@>K%vkdUa)E;deJtb}m-;&^qROj_OV|eNWV^5{3xU`uSEQM$Z4t ze=Y7ZekSR22Bjp8Wuf{0MaDe4G?B?u_ZxVU?e{Rde<;wqgGXHa89z13b1^@R` zoy7&*1*65NI(#M<;ZplgG!*(OEvT1;njEviMCV4^e`88tR~{hQE9Wt+Vn@p^jrnR@Qp>SC3j4cVEMI-oUj zbLK=YF?K3RYCLH9=^u@*%RF|K&jQonce&^Ru&NJ^K#x|JuYwD4Ir7?)I{ctJ;$ zMr*&{dvWm;F@)wKo!{%Ez|9kIpxh!=cU2vd?Xs9?(LQd>zH zFhY=6*zGV@aPon(!xnCe`Qayq-+^2H;1i|i8Xi+09lJ-4a*0jq=^>sGW@|UcT=}$O zfTTZgyRsR3FM>cE4;_fxLs2^?6Ewy?mjIk5lNP7K#^z4>ZRcCUj0;BwI%^yYx5&Ig?k%dHFPt z(JO0o?yB}0P1>gVx9Pp8+ZrZqPUWBJBr>efrsOTOvhnZP?gL80gHmC9z0M#l4ygaAe&HbFz@<*G{Xq3Y1Bst9}*gZ3KY`B20F( zjQg}JlUe2CfU=tG)`EGXg6MVg(8EjD-+nHfnwPFMX4&X0EGF8u&&!T#fu>mQ}v5fbhevQ$!VaSRjqO8xM zY)tgzMlaHuPQuN}E%w5%(n z7$lXOkRI&|i1%SiJ^CLX`f^jq(S#e*i)F6h$wMweN5`G|NA0j~pNwp9R`ldn6d~#e%dI_MT;LSc zuLUbHFs_#%+z?;=dg?|i_0-_Aig2mN*CxGctk3a*UI{aKLXjtp8Hs*<vA$@S%_K;eE*Vay+H(? zT|NM0&5w#_EdMk9RbCmbclsy7z6^rL8P827JPR=vUBT}ybW)jjNr5Zto>DisQjiRi zgbEw&eQv#F_oDBiCe_(Gy35j+#o2#+^ixRxQn0OaK9h|$^om`U+wCv24tf)KQc+ts2Nme z2eTq53pZfDQxx}2KtS$euOvz0M~iIo-oy-#j~ZWbgnTpJJ5hA5U8Pp{2Kn$w`baHn zsYjsC`y8xZed=xS5pos@`|!=7Hoc|5OR#^u(MV@Fg#z~z`R@wb*4JCNcUgeSS-@6F z#`y>D5Rp+Rgiez#@457Qu9u)Xlg*NiXv(|LqNt>@zo!VUn3OBK#M}IjI5|N&fc^fo z-PCDr&ugD7)=J*K_q3TI4dEeb2Bn9BMu0>*v|56lK(NuFl{*eU3QrmR8G&el+@WZe z6hVep9P_#|M#I;*}*YKGmy80%`70kxyb4wjyT^q~bzz2F*C{pL5v4y(^mLnvgn}>&9Y?yR3JC8+w57J*K-@M7q>%$E`hURfJIS1vMK2flA~eWl%Cc(L zi7qH*D|RmPTRc<9g*@tX8du61$guP~(?j7OYVngI2GG~)*~F+dM-`<}Ay2fvz@J=6 z3S#LGLI_IGugYh7~Ka3+dy#ESehB-j#7m@BPs9e5`h{@F?C) zJ*tI5#Dir${Mzuc-wB_#;Lp=R)xnpAl7Z{8%;GU$maj_BL-w?=FxI&<3P~vX>Yz+I zPe?L3U7VQmzDhRpNzx@?Yist4sWO3bn4X7ao~>T}Kv}@7k?y zDJKJKXwaEIK4HIol9I)@bxCE}$xzN%Mh)hWbCh?_K06Yl4)j=ZXI5=lI)Hh&e!J<0W!+2E*@ zRSyNA2OJn2##f#n*b3W+uj5bVT~7HE3rQBZvku-mWaO>=3%jz9ZDOP3(70xbdilK5 zPWL+=X7_T0rda`4c%3AA`<)2dAWsWNRa#`repq1Z?y6uYO_<`S!l{qSw3Ala)rsJ8 z>!bFiB-UXAH-mRGo`0$QWG=|1+Y!V@$duIg?w_fODqs_L?$f0glwP_Sf%!A+AD*vz zECobGYi<(=Ed^**!*j>WqoGbB%)Il8&mtC&ivkzI!1tLxxE&X|81` z5jk~ewOhMv|8dMF45M{M;JPk3>NNdRGV2#t%>A-W2XgOV}LzJe-PCP_y;RIhvE7=m=`x2S!>^+M|yY-MhFZd%p49&Abnk=iWGp%6tN;JTD`U%cx_n*ng%1RjQ2bzy+d6a}$`H^jmVx5nsF!|;> zbCmqKPYhLIwZ3UrQj-$_Jrcc8JhrD!()9NFZV!bgyzTaF{E6xkw1vL0X|(D&~VC zJL1NaEQ}(OKN&B-{B_pZjtcVcKOUtr>esI1w^#LHtv#CPEFX(`@8dVwGkB9q_`erc zI4#-Ky)EG5^L;A&Jb~^z5g?H-7pIOB&HLU`nSff!ve}unJCJ)`L>GEu?D(# zZg=J6Q)KBjCBT{d;4mu7>KdI9{HiZ%#f4}T-fRRFH{}Fa+281d$;yQ0x1M7Gvv5^l zxz5r2ut{L)6`WaJs zp(LB(I%QJ|v5z36)$5t_2cx0{OfZ@FpS28ASkU@{_W`9~O-Eddf1DG1B>82?@^T`p zfu7tKrJymnr{)NqlV4aeVk`a*DmSeBXY=eQe{loNr$)NpHcO@Q?`{}58`w|i5!y6k*nK!gOz*w*IFUv@t$jXoz5=#X4PH~ z?#oz<{fm)-uh&0HUCgnsEeL`32lLT>F=PXfSwL3pVn+(E)zYRb>SL!!S!Jfpdz(Lf zDCoS}BglHE^dB)?uCUGdtt8P>7RT9}U(;@oO6>M;PBqOAwm$Dheh zqfpv{T%M(2cz;Oq%!#N*EscPuh~_r4s3<`NoY65a`{3`3c-c%IJKytsrZ`kARlE7s z-G^VV>fbCwmozYf_2^^{gCBTMhi<52^fMzA`~vA1YpWfrO%6}-*#`oqw(UeWE_h`p zxs=PI1&|#xPWJ*H5_QlP_H5Rl6IF#I!=!8D<39ye{qcTWBT~>(%Fkt}gmXgf&^$JLd0FCBJ})#NY_Oj$!)@qbEf>G(Ui&o~B?aAW8St@QuB)1Y!tM zAMH;gZyk_cCnC1Go1q7*^H=D5MgNwvB!#PjBY&4G{KlQBRhUAn@9gdD0XM<8a$7Li z={ojYs=aDdj?Qd6@TkBG`!;RiR#N#x2zjS1-0s$I*w15RL|#Hrd#M)x{B1{clXru@ z81;9x+IpRfe~;k56-N@dQu`M7k4woUR@|`JZA^UA;NT z3$w=gB}G$bPcj`$_*Qm7W;sFLCwM4lC|Ps-IzjZ?plAw~IfLn{9x)kB9~xrnttWdR z4>eG}aSMC$mjaikflN&KfJqUEIlFNVbnr(5lv_Yi!h=EeA5jf)vuaz6WY?ISP*Alm zkSd&{VS(_i`!FDpfX}rA(FCET^}>LyBlSn^N&mGHz4F~$9PuZsYu%9rA2bpTVhIZa z!}2#z`ctoGL~GDjKTAuj)cjX06x>Qi0|DUkCONrv$KP}V?QD#AQ{ zNPH6p_nkwlfQNiJdtF+=ou6Shf-5C?r5jvXc5OHk?jjbT(z?z-_Ud~S|AO&CxXXcW zKyM1EHICHf{4Y#17BH%iY#XeX!weE4KE;+H(!D7tdUG>8KjuHOz!-Z)5m=N+>I7=w ze$G`?Z_g1xb4TEbH<00{H8p!2&Kr$-(CKUv2UdE=jG0vNQhrkTleIjO>#G-0#Rbl? zj1n&<6rfRh^m_C(^wepm8+E>;%mt=^Awp)z0jDqCnn&em(&>{yz{Zj8!RO!;M7meM zELgcZHzc1c2mymPD*@%8N)OOQ%hxBrLIRCuQN!6#oOSNXOZj<=;p8k8T>Kk9gx_xn zJvXp0QgV=NVPQTBBJ7*Sg|Su2>*j#Qu%szFTL}uZRRtUFEf#q5XnvOZrAYPK>fIib zls3hemqfc*S49H5S>@u1;wu-NbN7|9lsk`KnlbQaN&Fj-WsC9x`z;!Wy!~jl<{`3V zyiyZ*H$#4FlrRu;ed^)@rl}>_5A+J^8+YsfFnDenQyd3IceiN}a#vX2K34Fw_}@VB zkGU5qjR{IKwm&!dVQ>c8ZRmPYOojJE)T=G9ebS}!Uk3i#pDGvY4#z$7cjhFg^9V-4 zgS8)*=}s0$??CjN?`&`!Uwoy0_Bt3xx1zUr`MSqK5_=Jf4T#vWQCa&o^p(4$>z`FG2s zVjr_XV#Py;%#^AMcdt+Q6nz+T1BmFT8GE>%lF2`x-(*h?9~+QZ8aEZ>`odcn<>qNp zrl~RBH-8+}Y}RhQo?Sm^%N(lT-O6B){6qZRfe{L%2#nPh>9il51PUQQp%T(u$wUHD z1SsPiIW&$(5M<4~k`|$@(>55+LS*f^#|vh(WFM~tn9gHR()30>+&|Sc3XH60>nhC% zf&I2JKnjZ_zaR_61hkr|+1j`?QOu;Uf=CR~ePx})u3$RvHLA&1pti$zm&(>nPc#wO zxxqqK#OdX>iM<&C?#s8_pFSB>J5x3$-#ZyPww0{?5CGsU3ZLvjxjROA4tL;j#1pR9 z1@TbAC{*xb6;FaY4($Ag(bxNWVw<#lu3eYG*HM0?d8mg zEa7V*HRvrY_4%f?W25_Pv+ZZ8?6RAsJZFo)T2d%llVj9Burc)yQ3>aKxlBXenFnW* zx>YkhvO;sJ=cvUONWM0zoBm9ZPIp>A0|<%tROn*<2J`iM$~M=dQq13Z6k5S^>&$W3 ziQG9$Y2wi$*^;W3r`TjUc`6P7d6+2 zpbIw8)5YbdTJ4hd{3fcB$Z&8<6yy5(H+1u739)3l?TP6X7`E0_4^OPS-xsioB?^4G z)-mkva=~jqYI{^uyW_c#mJh1u<5Dz`)nI5qRX*LhsZF z>Z;b#lpjOqHCV`wFIp{|DZS}`tY5zQ>hCm6BMdq&2!*z-dJeOf<(fUMBC;%i0AB3l zqAG?^`&5~IY21C$2X2q&kS`iGCiNdgScQHSi9gtTK=K0&%*(Zr%zPs^e_}rN%%zq6 zIJ`E}Rc8zfd^&mgakM1GL&a#^UDcpDbSZOd3$yUk-RRW;3B?hd*6?NaUIo)+g_9vP z8x%DHGs-s=B@AQ{a-%8XR$g+c zFXgWj#0wko3XjVT^j=Q3^nKlnoG}g6Eq6hRs&%0f5+l}QNtb>8(KjeZ)iI-L2*c&c zp4eY!AXA*tMYh~_+^shMWF>z{yE>lrZ(*8bj3B5v`$GB{?GG!htcenIu{8Kz=RzGt z^;%I4DX^cH5P)O|UU^W9O&1}Q$cXyxcH*zm%f?71`KCw7)_%Z1-ribA3w?twm1I9H ziqt8H#E;%E7gJbM!87E8)B%>Ili3V3{eC4tAf@*)dt8vqyMz1kn0PMT&D!-O&Iv9; zgv}6nDSKwv^;=#Qf(-_$*Q^jB@veVON|bg=%4&~%dIV-`)C}lpD5xi>l zNExVq74^Lc-$E)W=UpsZt+_dOls#a15zUD^zmR_g$DlPXtxsL(K}!yO?E^CLWpdUt zjt|ns90I!HZXzV+c00dtd*3+~#^IZo4d_WoDd3_zZC-$=ZAk1A!A#}&S^Kf6?>_qq zYu(0{?YpXIM1kz2{enwWTju1xJ0LM>Pa4nRwan(C{Jx6#L_0Vc8%cZmcM?yhCsDr= z{rC%Lqn5ngN~(*&BzO9MOucnfRPXl&I)I>3Dxq{pcXtd(NrNC=O1E^EA_z)KcSs3C zH$x*0gLH$`&_fS7bI<4dyLYYo7mJy9&RhGuvG=o|CuX>9HU3-YPQ0iweE84$n zaDIgmLt6zCicoJ28-tCKl!* z0hR}~$P2u2e9{E{K%9RjDf!%<#Z-GQ__JeY$z>>YQq-`_6jOSeWFhRYK3Vx`}4jYd= z-9%>bInKTT<*aDKjw(ejY7vI~9=AI-46iE`~le#?bSPcrr ze|da5&~mP%&3Rr}(GZ`|q;Vi>*Si+)|4g$nyOvqARs7$A*HK}*pNH!|CR5HKdoKb? z{um0DxCC;)gw^J0J$0S{4?JqW1E|0;=cAswMM@*d3e!0ki*IIg?g@))X>z3M3#lR^T|j&zM0jiVMfCy2fcgsq>qF$*2Xh})PxxtK*Iae?&bKc84vK8=etXgnJU0Vdb&bD*dT;Hg*q;6?DR)Yclc>}bSv z;FH5Y=lex9Q6nnt?+evLb9S}m3VVeB_m@2ncHgKh-bMFO%@C?9#IX;udc+7cZUZw;@04HZyq@{Q~GXHCAb~NR&o-T&a`Yc!8`sl75bJAcaqgs^-6}$R8g)iyp#o zk=vU_wa@XUhx|>;r8XU2Bq!6xoOzt&#nS8Z5%5soyJES`L(?nT^+9xc=CDXy9EuYH+Dv`*~VpQo%@S87*#%teKT^ zFB!Bc6>U%qOz4GCR5rE5xXFDuU{YW5_2X8NDlgc#`*1q@V(;hQP4|4kC-G78gU1I? z$ZAu)&aGqJS1EV4JUb-)isOJRrBK2-VPCHAJC&rv@{Ah!?B&XdTiaQ)`W9~>7a_F= z>~M9?!#H->d2N6FK%SuR>%CV*Wh6`@yp3Y(#)li&+_WF{iyOs}hI|3{(K>kzMml`ZQtB7+hG7!hEKX89(<4cX z7QmBV<};7q?rmn-YsrCI!?21DHtj=%g(i#i?z%b%s#vDdWikUkfTBaCM+v|-vGA>tF3&fp{Y z)~a5D(L@zBgClUrXjGb#4@}2?8<}O%GVfN8DJD1hFz|B{H3}9QU19(dpex^wTb>%- z0hGxD*KVT}k@(mE$mz3GWtF^!Axh)!)(rH~4R>I)2vzvLuD?&l+Y#Ix$}c~)>8}n% zk3Gp1bpNx0@R=SuS6CBE9Od)5=$qmKSKFN&L4c~vlF*0IAge6`!zFU&e8tiL^UQ3g*RQGtY>ff>q`xtA zl^*CNxB&HH>`H#;1bgf4=@~bw6)2@qkiZGVyU7QS8m%mYQ1`pIOnyHNp6Biy6k~wa zjUr9@9+AfAJlWy7?7vGsWwm3!pt+?V9g)9GdRaoxjG_w+Q~;*)n*fYM%)Jxk#wWCA z98hfae&bzxZ{podJ%#0Pn%KXg%{ME5^aqq`E3sM-$Sl>PO}=IFQeBDeNIsrVMN-;n zdP*=-NZpv#=UMj-EKvO8xsisb*AyiaBc{L6VSiwer38psD`*&fF3aVmsm#5s(SLLE z$tEeZ;YAGXgD_Yg#Cmi!~YPfQzZ+P~A ze%C!q7Tzu`YykZJ$M>}Q3lwy(#Pp@#TtO;u*bS+~ zxjG79-(rIX37$DR{qY1XsO`X>WMFzBc!m=7MHfHk1z93X3z^`^EHeC1C)0|u4_?Lc z4J(H{9;!nJFv`eLY5ru%w37B_F=i$D0~% z%oQ*D1qv@>etPb?w@V3}$G($|pC8~66Az{`;e{MI4n4?g5XJQEI||Io=ZJVY34VK1 z{4#sZW5a4Nc%4azXsxHDhuP7Z&=mRd3zItc&li~5>BV0yeemB)cLKQ@wLk2#dcl$$3+bGi0ssVrrqX3W$K|bm3aeS;^UeBYj0fl19=9uKc z&G6UhpM@i~gw!GcxLbPT&=YHT*>M6ysuSKfm`F4tUI#wS`J6u&c<4(;-g+HRCytv) zDH(*adoVC#5orf1mW1hBH8aS)mA?4#niI8t2dOq98_jwHDPcYaFqBk>Z|O~(QyJxq z(DA;CTV9?gV`fQDwEt$52=PHFIqum;oJd9J8}lujt^uSI$KQ64*Ws_ISHu_ocP#D{ zbf^f~g1|64yqTV)$1E5g{^B@!1fxWMMZfx{l)T1s{N+MjT7CVcitg)7Pv}lk=wL8h ziWmb#Q~y0_h6bSfCIg@-m|UN4D};<9SbsU7?!WC6>!BL1dWX*g2zjpbNp_K&WFmxs z+la?H0CH3qE;uqG!>Jt#fRT(YZQS?HcmWt;9|_&dlIn5y$r@%N#=cKEQU$xuLngMD zxGG*M3Y5e{1}YPL^ce|Xz40FtGs}76Br87oug~oLy3yK5JnVi1uGIOuu9V2)o-cri zrc}%+RcY~BYGC#6I8UOySlBe5xw)g}w!f(=1rER!_=aO&w^ij=vgI(N-`yzI{&gi; zR?lU1m5fyXcXsXBLXSLuU}Ion`}I=sqD(Rf`pxeI8=DCEnEL4Bp9Z=k^~l4^!tiCi zG5vDZjZe0*m%_|)S*cP}SHDcy-}vi}aX1EgRqmXGq|~1VXeh9UPQRlDYcL-&+8jrP z+pX#t9xb;h3X+sY$deo4c{s^Q1+)SaEHMBc_tkvEIkX@ZO>_F#R2jGY>x(gV0*GLT zOF}KE%WGeYxxktfrPg2NXB+S?Xc8$UdGx0b zh&Kl*1^)HIEw$tRS$JQUgq~2m%TeAFJ+lMc+W}=b@0%Ie}1XhUE+f5+PtD@OUq>p;UM+!*cHXqLH zjl3Q~h$)^M4jL{T)M7)Lt{4Dyh+g-YiVP;_ZTabEN@DTj#aqGYWT3oC(lbnG6!B?_ zq>xF5UDg}1-ZH}cpWo<({r6rfq+WliWCd(2ekZxZEumOOX#}fYJ6xUuLpxVvP;nb7 zrTtgiQhm)GD*vbozyri%SAXc3Lk>gLfPmgf4bYhOqp^QHxH6saN1GG@;NfdaAhNQy zDd=$M3>_W2aEYakJJeFMrvRtl{_q4Mdbq+U+C1LtLAld;-@ z8u?PQ^NG>QWQl)Jt@3}FQ0p98QVt*F+c0C+1frU<1*qRXkGwXr%#a~ia5Rwkt&^SI zl!|A%94Qv8Nw#d7<1d>e5B2g+S>r(O1or(Q1XWasI;%?z$I(z)c>M{fvbR>yWKP(D zD0MO}b$bArfmYEF>S4q0mk!eP$%5&-2>6u>kWz_L`89T8^pDuP_K=1%wIup`k60^j z|Cw>tD%}G&2lp4#iaT*{v)%;Y%BdxBjsT0U*z6NsV3u8!GG4sLD;(mD%AzrQ7!Sa~ z1l=cN(eEN&dFVZrb^JSS4Cw>mWX7g;m9r=4Y?}{X#*VB|fby~>U6k<@GTA3=vf3C` zZyXDLzsE`H1E7+~>eAQ6kbAmev!q}}fa968<^E>RK!Q7OhEzmX)Zd@}{a~C?o#7sB z5rFUAYGr!?T2C-RH;jI3*VeVXC|f@MLCK-bV3|ODV)@_qA@LXhHT|mNQkwmGv)Tle zm17Z80PAxrmch5+|4vH>%Vs8Q$qDzz>iWzNq=)^mimO-KGbKMO79$=4!1Ib~#9*Jvw$VN*METoB@ErmO z>r9f>&)vPXSiJ^))60X$!sgrKH6Pb`oQZd;{iO<*PHpwG)F=C|;bh#c9QhlgyI`{kUXH=? zEZu-32|-oG3oW0{oiGmm)tJvi;$5}{pJbHDSH+?1{=|IdGa%4%{DSU3nzLqVP}i~= zs5=NIs$J9H>SI4|R5>WQH&7e>V3{kX*W0wacz;UXpwEs99?UXVzl1Ld<5ELH7Ozta zrKR`AlRm#2Z}E1Akbc)G-EQc3SkZa$?x#k{p!gqJr){>1mk~l$f+v$(t$&HRdG}nh z@E2KTnVqK%W8_DCjj%jV?!9AatqMZ5NE3f%cg~Jx5{Tw)5xQnynce>BfPTDtFd17? zPhVSn_U&Phb;-fN%wDsNJ3+$#<(K}bXrbA5)u=?vqZAw8*Rc_L1giy(zl5MfhRp+B z$-Z~WFUPY>>vAZH(76;ZkHQE`3?~~u$_Caq8WVrlF|hUeY~`nT24%Bs_#%D!*f?rm z8?U$ZhDqs6l*PZi^tO7>MCQk&LrRGd5n98MW87#HEBx-+{>qVd;y4DAo9}^EfEy#j z2!HVV4*>)-(1(DE2L`wt!m?7?jOyT5O_a>0;vWiFyQ<54^U8j}WwX!FI#;V=#D=*j zn4VN`=R`hs79kI7R{W!!ZAc!Jg7KF>WV^aVCoIo<@^OsEN2*0}h1YJlbH5c=gJ8y@G=qSU1_@o)A9lCOY`E){dj*uJ z%!;gd4 zG~OF6K5Ss4HIA-LpJJK1U88ujr;om+Jax?sP54q&_WhDw_ba__rqHXXBtMQ!wi?09 z`W+Wj<6cunjeo|c*~on%|HE5g=6w!{-s52{@yMz*?8k4?d)rQd&q-N`SvKLxi^0sX zO~Y@-M@&-NnQG*c?D_l&T>Z42wUR58saD0G@;}}#z4iCVuUW+fsQgiLjlUcPvoYk~ zF9kf7v%ok59egOESQR}sjpoIlDsaqY)JU`2#ER-?Ypn%e@3^Z0xANt=!28xMt!juHo!QFQ;j+I8lEryk^H4J>U~!~5HYuqdn`i?Hh3oSwAF|0 zF%G~$l)fP#)K~~(z1e#t!*3Jx^;!*&BPNO~cy-R^8?2snYPaUaTU~4h(h9EfdDm=; zPg(JIHIe+YxhFk}e=_m{Qr#^8W+#KP))}r6O5z9pg||x&^+0?PF}N^c)5$qFcrHHX zoId2ZxFk&;^#)Bou>3K&s^u z`|9C|T9RM-c+<91xYo2d9i@wf>V#yKgOhGW&9P~L3GCN%15fpili4ADkf**`kx1Pr z|6oLhAx=n!meM0Rzj@{PcGX$5#c#g@)=;PC+xV{3wUqywr0UOmx_MnHz(}nXic3+2 zkMRIy&s$FVYRAJIfOc3Tp_zXL>NVZ{qHfGsiQw0z3qV>>8m3>I*%W*0}K;oOL(5D6+yl#T+>d3`4VvGfW@jVcAz zW{b;8NsSrx#W*l^F@(S4p~X#ek1&rW!npQ>ibWz$6g{L6l?Ft%cH z&1CXkFf73s#+qxY*9Pt=U3itNH@mlA&23r4G4qgPxyQsLr)N}W$3IoD?crdwpq9St z_bo3fI-@yomwBb&j75RwRto+iN<^C=(U6-fNKcFbp`Z+nk#yShBt5vN5*GBQ$U7Z7UQ1!fLL*Zxv;F$ z076IGZ6~aCfG9aodh28zA^;?+=zpRF$;55eyRV+5vy*%|1WNIBSm!3F8b1E@`n$lD zv1&Bqux>ILWBi)OH;mxo9{`DK=H3d`T@`lxPavtm`e)ZQ)uzD1GM4&m3|Z~oW;EpC zTe$n|zOfgXn5OT+M9p%0S@42T6$r@wv+Z015BqbhkND;zk>@*0b~>|!f-Vh7 zO|do2U>lvUvD)E3?%s^b8ckmYW*$ie>V24~smKvAy(%q9;TL)-B!n3w0mpQu7Qrob ziE%~CyU5R{0MTAG)2WQ;wneP@-T55_w09h9T|d^R4(B;93W2K^H3Y^Z@6$`5oTCQQ zeq6_U%vBY85o&lmsypp@Y;-!%_fuDAb5<1oLDrQGkXqB{Nw|W?H%r-1%Nw#tp*C*?EDT7RaB|Q{HHaR>36>mZF z-;}U2*;7hfiE8*Tz3B3k)ncD$UZ^%2k?k^$+*+f%VfZ*l$ZC)2aa`O`3x)0 zT4zAIL%{U8r^O-TL^Jtj)i&ok_*J+F~+TRav|9HiaRX6-U1|1&3`mFb47Nk3M>Syll8#-95(rO|IuC_5#?z zB3`fQ_4d_7g|q~)VTNrz}MxC)x!y(n5}XAj*=kz!36IZ zsQbO&Mi8G>OlQhDK&NI3svN>M#&g^2UlPkng?v=Uo1g^lRWU5vgiQgVoEc5OSAiij z{Yydou%xlMw;5Q!a?+q%ccrbUenATHTF_MG@2}F8Sj>o)7)Bd(#L(hD2d2VuxBhg_ zo#te=Y~Oh~!+vB8c`@1<4;vKrsg`AOq%QQlSmq`Kv4}T{@%26YYaW1XA5gFx2RKVJ zS1z>SRlyu zwaep1yw8(i{nYg_oy8@oJ}Lddq+D)KDc%7bWsG;J6=}qR7vb1V1G|Uf=cFWf+}|ie z)T@DF!{gH`3YsRLBSHa^f>gmiIKW&pKI{VNb{ci~+zzc1yed_FK?1af-ZlGWux?(E zZWJLTXo@d$Z+APpsN9D&af3*3YZD^p7jYcdxHYJFRPbA*jxY5o66nb+_@y$?hW*po z4^pYl8aDh1W~+u2#@Q`)(W+YMz|vg-w4ei}C-{e{R2L4>d3O_HC~+Ri7oFKqE$UWR zd>lLY99W!(__hW~R>gn6ZIi@-87rkz+)Q#d5eoQyRXmK3sj1uNQ~8&|w@_XLUN&hG zJ1AY4dH6g>!~D@>ygdHy)GAjm8h=O3Wp=B3yGJa$r5G~`U$+_{Y#rG&bvFBI&~LSR z%tjl~AbT9J>gUdTif8fGG+?ZC1o&(Wx%1-K;*L(FaITor70$ zjtqQ*Y-xqIi4n0M+}GkzZ}5rXUt!3twBWjuAn!5h-$5Symb@!~I~z||n0e*KWB*ui+SLOBhqxwF%Juvm9FxROU~9Uf5uGQum6OY=LEyX`p6JnT-?9J0~_ z>55LyKm>2(wO!XVjUK*V^~1iuXhOT)Vws!Y-+eCgg_7zoXg)woCd46OQA zB8N--tAYVusR1?WQD`)f)QQF|k-tN2qDz*y^T8KO zp&`NBu;tE$?B3|Z>UG5=%TBm_QAmy4nBQMe(`+8^WzNkMUJsolOMmr?{UI*=pK_Ms zN^ZUVxL9+TZ{vBX$#v;c`zN+fJa1HOR7J!K^M27DO`rppuFpg2uT*J=XiJg%B|6V? zp54RIh==UXlz=*t#Gh!i>G4m;`wrTLb60d~$J{c@V-OVz;Y#=b6M6a!Nu;R>3p|2R z-y&R5*X#OS0i74A48dDIv8Xkl5?aY;58Z1<7nvc(h-A&9j6>h7C{Fe_X6ZX;#I*8NK!EMzc<~DL=QO(Pq{PAnjFq4&MxIJmZ1?p;mg}7y}aBQ zI(#SmfZ7-PW!ie|k^N9GBIq^Nj&&<*X81o(D=l@XlUV%(>gHc+AQET4_8=EkGL!mX zb(AW-(20Dc)&b-S>J zJ_-@J?*@C{>;RFs#gI$b)`C;N_S98|<06q}%)r|9Dwk9LkVtM=N>K2^8653>^~Kyc zWJvSZkr%A5OOjoW^c?O=*`gI^4Zv#pMpESPj@mO#u20_{-<)Zpnf80IKwoL&kS_e#TRlo< zIxwY6A8>M@e{=_JZ08L=D9iu6WqoF1b18V}xcIIKt?l3a&-`Zz|KIehi3#@7a_t0*KA>m;Jpl7dfi~1$h*8jjCg73_j|_ky1L;Kh>vGX2 z9CWZD3|y&~K#eSG*C<-^GvdE-1AUPapm^V(KM^YFwzfDqmT5*jL85ovRogkn(UR#Z zQUcaiSoQalxlXs(V4T5291Xe8%?_ZUJ=G^Bhrf^^wC zH&s4mJxt)`9WZEHf6B5w9!=%RZ(IQQE9 zcZb?F51PvP{j1aLg#as4g&mkGV3^FZfMHq{#$>*`r zAIhl&S?rU^?R66;oJ;6(W=s$w6_`8fhwrhSr$L7Somw!-hxG+MwHY5XAbxO^3O>Gt zquXy5E^CkvZ>M(#sg8f3Y)6iyhMd82QO9co59_Wk+>!vha9oTm zpYgjS22DH|huoZlQ6*y`*KrFFrW_jM;62TW>g12Wm81>xzfA=s6L7cu=!1pj{7-^y z&9Pc7F+s&bhA$2q)+62aLeVSr#mD8jn*X;I7^CmdS{r3AK5f>Ow>A88%-xgH33bk;U9!_V*QfmO~b*wNx`w7|DGxqe%rI(2Z5qII1NA!HXx;(*s=-5P4`L1 z&{}7o4n+QR)vo(;&i-~FVIrYv{HriIGk)k>dj&*3T+RAkbND_ar==5Ka#$~pM0#d! z`Ewe@1jG2U(q88$vfU)re9aO_zW6jem;QjhvwCQvTxq{QCb+TekvjP$sfscbj7RPb zb%+W5L5OuWq1#u6@kt>yZpbS?0uJ`EvFTAq0q~o#78O+TpxxZQ-ou?WaZ$HXS?1*M z-%0{Nttg?dde~766V#$D{O%)rR_5Yr4xB%H73xgb#8Rf2>KeauP;=0j_>v&MZ)9D52`$4Lh$qGcPJyp~ zTA#^Nc|jXid7#Sic#utJjaYuch9Kl2#nv8NPTY2HG9RsMyulD>z~q?kM^{Qhy{ z0p4#dGpkjO0)g7}SacIFfSBm zCP4RUA6quk$vA?nedmb*uPe>luq@<^=!++i6&vQ6N@g8y5S{9Mfcn~%)^los#*T!& zq?nV5a19bib}`p%?gZD7Lst=&0S=;<;W|dWi%Bg)HHD>@|8*`aX}1b^M)zRK2w|uY z>CsFyE>(uHhslgE7pCUwU^ro^w;*$iN~*!p<~MTzdBpGAx( zp-QU><2@Uj+LwwJIQ2l7_vT!nhWB9xMricx4lKa33IiCKN5WduP6Bnz$vWK9 zoG?8GsC-Rv;`wa8|FfPfHN^IHzc0vUXIjV|D4mU*36CCy^*)8VpYm$6QReGgF>6<_ zeF0!IeQ$FQI9>`;OM1&d&ZlmFfPA?703h%5Tf!Oa9y+MYmOw_F^=Cxr%jSQ34JZt zZtU|2HOr9yc+*tqYg6puJVn>LEeOMyeY`%i@z3;iI8?Otm5d@k@1Ct>gbD6jJivskeCj3Y*;@e-T^Q zNvMaQ-ng98PGsez5mX6>>;;I=d{{Ykv0^eSK3Ft?(2CP^lfK##bv!g?PY^?>=trbQ zZA7!N9#WlZ&knlIzGHeyq!&Uh+3xdg&RpqfLG>1$*b@+4D*KP2O*^+0n@Z)c_h~xy zqxnn3@j#|eU?*HJr%VjN{yvcoLyPzisc~opLI1a6>(F}}btp=_HgqN+((b`4iBUym z^I*ph$Y_vt!x%PRduP9sQ+&);-%~A0X-NN1M}EvtU!2*Ypm~FpH#~i)av1YwEW`J~ zD{HrFzg?~#B_54t`bs&=oWcsynqmj>3XTuM=W?P$#XR7ne){r%9w%}3P+>>#DhhJp zZHYK;T+DUP1w;Z50BRl{6wQWAvCJxXXPoRnIeLC^@Io>BDE`*t$FEgH&6z(|LQod~ zV}{Ab8)7@G*dODeUJTHnh=a3$0Sd>IQ(;sT6#CQ&55M*~znuHa!d;0Hw_+_v5}w{3 zuznyRk4lq{ORXJobh6qWI8CqKp-$#*aNPrvowA}b&MW0vdHbe}l>5FF_a7s(w1N?Q z9Vqd7_>+Mt^T*{S|9gfu3Hc-$7Ox*HwCEfw4GbQe4I0kz0K!2_ZZ5F5z{ps=YQG8= z$BwARE_y^X^dxZ=A!hR6r4@~^xSZ?$`+xnEi5@-Ol#RL6z_ysGyVH~`mW)ZF|8GD^ zNSG5}4C60IDM53@!~IlbUU^d$XJEqn5c8jV6#08XG!Jv+T$TNd1mw^jmPjh^2oW?o z6L3kc!I4zT(xqI%uomXcJ?g092KJcUGZ*c?SCeY#PUK2ee}+WzVK%XCU-D(j9O_z# z^w^{Wqa~vf${vy7`@0t=6}WGHk6rPIsNBw^0LjH||2(p(toPbHKz#)Gfz~Ou+d@#h zBxTO4dKbBozFn^*T!L2$*nQ<++B`L8yQt&7Uqk=26+biV5g=(>vrAJ}5G>>B3O|-s zf%wgKW*2;OXOdWJ(w7*0K={n01F}4d#i8eL-+vh%Or7X^itB4P>g4iZ{tJpQ2DajNWbuIRKBTj1<*yMH=vAHC-)=xw%Aa1q?WZ`8wa zjCOhjtC=DN4WlJJ40N`wB7dInZ@T3I?cb^(eVN|W>57jZ`4JW{mT+^G z3qG|+hBUHB%%rh=uR*jIkHp@b|KwPBFyhIvyt{*s#H@H+4X4(lx}jJU$8+sx%&Mki zkQRBSxHivNZPH)6BR{TI0g;O#>&8yuuFsvFk@tSO7gT44xrjVj3aWsTyT|>%bK7+4 z^~pKpfvlM-)PsZjQk8jOf@9&PUCB9vqW8oA!P~+M%)HkJ#IcbM~#Q|NGCxA!+Toz6a>F0#o# z9Z*!7_YO3g6IigjHz4X*p^l4)c+eDcpCJk{}D`vBdzIGv5K>Ce8J8zYPJ} zVAvOwBrLdJbx!ae7H3({fDn_EgTXoK)CX-_T5x0$)s( z$rTcr4;$t+ZwAzxlv)q}!l)YGec2Q}F;iDp3V8tDi|e}I33E%d_Z6ncJOg$E+wfJr+!(i>g!-K+kFxU3_s?E$X(6Gb~;O@CR z!UAm#0uohw&X2GEQ!rd^PgK>l=}j+s0;9v2D+Prf*fq^{eGEs?$-0``UxjqWs2$MS zc0IiR%?!w7IMItbafw(h4xFmqsO@guK>QwqRx^-%jyJ7euonFnR(q?30&0mr%kWb& z4LsXX?0)#XtYln{#xHguMby9HTHmhXr22Pz*piZ9^<$VntC%!0UTK zn1ti*d0?v&hA#QsW;Q4^1=-&12HF_LXa$?Sx504=S<9&$CobA6@wl=S@8{?B4nK;@~0D=K&$DGOw8IvtNnet5tyKtvy)5JWPAJ$Tc$S&A;Cn$G$>B}xoZVQ zm+X}z*FmTlty>M&WWw?;*C^r$W9;5Q+;#7*3rY3s&5JDW5j@5^ZTEYNRe4GFj6KC0 zMT5dO)?=d2L@NHdUHD0t*#aPG(7Z2C*YnWnMv8f~8Ni_i5dno}j1p%9a=PPYxyN~y zdGl1z9PofgE+6f}&0>c?)S8u`Kdi*!oN*iA!(dm$_*HM6Pyxsl&*rN<%{zD?4Rq-Y zMjPEetlXSd@>S`RPc3oYqX5L=yTzB_HK%!TW|e;=motQ+f7UtyK64dHhUxL&WnQ2z z9~1m>6!N8MFz^=sP#YRZi-%1v4f9soe7g`t z2%UpV1ScdImw<3Z*JcTX2V-1*SMvhgOfqo~z*R(w0c-gaAs%Ze(OZfkr|E!u3pICI zYbx9#jObJT=_v2MCyv>#c_8w@uy{0oSQg|RUi>^3N+_l_A7)%pq5NTYE)F)jIKqoH z^BAZ7vx>dtL3Z!-en)w{2ze|NzL{&kel8pK)GP7BcF=f8r_Kl_sLXlP6jJe&-fx^; zc5~76_D9=lQ()8pEAFMVIS=R;NQ}gI&Dq7I5YsFA){2E;3LA|AtAQxuyQiopt=@mb z$7W37g2#wg`kVF_oi?zB61nofTd8fG4_-<9jxM0^6uUZB6 z&Jq_`y*TYWU(;Aq_B$$)fF09o`2YxO3)@oc zKvoJz6pteUayD856D#_$Jb&Bzo@P!zPK3byzK$_j-O0z500Z{w`?b}86yoRr9Fh&q zFodNJ{vfE5c%~N9m-HB9P4i*KGj$@Wpk0$tcg@UD2P84e8zC%RX z`_7I%|1-NA&22@M_X*YZp_tU#hs92`O1`L(!k%1Y+S!n^+0mzSirZkRqIJG#c|hsc zgNbjivD1~I1LZG|L51QxazUKnx2-NUg`l1n9Lxb4^%865{gpLGR__Mvo*n^$d!$OI zxk^qFGGgWK3{FgUyT7ERZ;D#ZnE_)siIa0e*V-OP(nM%I6I`vpMH}kL>RlAdN-0UZUhK3@f+Qz)sAX@~ox^(&u#+AsluC>=xF90ybUetx)tiGWT36t~c) z^}5SoNiSYbY`T-v`G?h6)bgmBgd`B-t0SXH*c>_bcMiD^rpy!;jZJBSy<%+f>I2La zmaj#TXTV!zA2{Lkzu|hRyJ5@qiNA^MK@F%9$S?570fmxKKfql(O)XUr@(okh)ym>%^s)3ZaPDqY19M*B%n`CwxZIp>c&42&ZJoYNNXL$`;N-O9)l(h z8_VhIVv2kK&?c}S3MI~bw5L|Axrniu1cw-aa@{a$jebyzsn8_QJE|0NtOlYHXuBM4 zag8@1y<^2%Y`YI`FUV@-mS?6-o^UY7ON^V z5TSFd3D+%a^3B+-KrJfXkj6{8 z^NqiQJ|85FA><$KE1tfW-@WxEpHdBXeY;vm@{LE={eY1^^tT`-Xi@<-xb=#(u;lx4 z1i|k~kn)qTUkRg%Vo|&mO68F)8HH8?PaeuQ z(T?)<99|}id}AN_dm{#0?re^g4^9!#{9$X#e^$?tsDb!Pio-C(l5WY7bLH5zqP{K2 zvPxms+$SxE7{nViX6|#H3YSP*+0OLM7SpfYd* zz}`X-joC}_AqL*T>TC>2kR$xr%+(!~o@iWBMvEt)ZX&SWswN=8iDwK6dKz!c!fPHM zQE4rQE%TVCZpXoJ-r-0!d09Mn)R?Zxo9hdYdFj^dDV1qhvYR|@(O35ag99b5_YSO0 z8so99J!F5zjONkJhIqXL#&9&x2!vteF&>}Gtd;+fxZcO*3gs7+Tl`?tO?-1|{4-^|`4=YWxe*`Dl(b#Tj>fx5ZmG3pK|j{}d#u+a0~iRQb_cd9QPp@Bjz&Bd$GIw^;Paf?rNo z&G6v%6TW;LBl4D_U~gCgpYWiyaKB{5Pz=XW-1}>GekE9gdwr9+qoeO@+X!hV5ytV4 z4NJt&%|&?gu|PJg##9Cpu=8~?>#ei!G`sC|Jw+G$+!YV2SOWKFRGLEd9WGPu8u4!R z<6(Oe{Og~vi9r`}<;|w=^kAuVUi^PFYgBVw;eIQ&CbyiY%0vGKIl9>yn*QrgZ9h)S zMc`z8;bgs(`Z#%4SeZ@3Lqm3%g_F^OQH=?>C5ws20`4b}gjjZ>@?!!Tka7#$!B^+$ z=3ZihaQ=wdLEyu#<$|IbglcQeK!R4*^&SM zI={Vu7km@gWYTmK5XjRTwnqvaqgmYD+6LooHykKZfwuOyEO^?r68%Zg|E&H0cJ>E> zJ9$922RA(i3I4)YMkQl;uUa!I9{#NV%`)(VZ~X2pXDSm!%1J1ojr)M1fp$u=DjRiP zuk~x=^Kb(No#1}4xRYe1S+{uAo|!Op6JM_Fg&dPpLaC*2(U(%_Mf6 zVre+fUKn2N&jO}3oc)n2MRcTNcaE~(mmkAC5B$G9q zl-PzWyV0nTfB#)RW~ku$aZQheJC1q0L4z^4IZMmP-{`ilHC8<0jDu_HcDoMH$glc9SV$X@oiybsj)_eOX;yss7BK;${T>0EQ&~qeY=C z9mEkK=;VBmOvYhBXQvYshC+5OhCh$zM(Q8j5sG?@BlT1q?s`Me(_-T;aj7--VOx=% z=pQ$?#`ZQUxtg6JA$MbS$a8Fv)6L4pq9z(1f<1_WT{$Tg8QdfFo8aX;P!OPPY+T-+ z!voZ@pW7C-7Y;_d1g$wkuKYXFNB~F7*6w=MW0{yh^cr}(T@8hN20B?n`at}={R49j z0oMjoz47V!%F*c=4S76M9kQWgI8Sl*KA6A{CFJu}v~5Qfp2lGa)V4l1VrwF)u-E!w z^Jwonnt?W;#niiZrDdH~E$T#rBbNzX54LU6@>b2D>)E!ax^ zSG_}DmcFNqoJmEm2ryH&Ox$k7zU^9R2koPto9ykBT@>-2y z`?74pfcUtT(Ok;tini>g(YpouSCp{w)O&&4HOQs;D4W~0W$!}%z|+r>U&IE7AlvUC zjgNB60rY79MrLqQiVqcj)g7 zA|Oi-zJmk0q1BE3j0_znfnqvSksO#m)bvq-UlAZsxa+egYc!xIFbkV~qRV;PC=Ll! zF4%o4yK1QhhDnCHEEL25#kRvq#CCMp=#PbPo7rmEIj)rm@L zH3LT;RT(=NQ@X}Gfa2Nej^WG#iXjgPl$0T^S;da4g;^3TPsk7>7#|nm%0JX~mY5sL z1y$Ua+N+9Ik19g4Qehw9jw6YDO~DreEF&@Kb0r(VV+LfLoxJW%hY7CVe4@SS5pxc4 zW|xm}Cn>tyUUB#Q(~Vpk{^NG?EwoVSlX4btd<^rr2&I3I5p=bS{|T$=lPq957b%SR zE1kp*@A%mN&9FtL%9Y3(bf%F!W<8b>G=AxuwrurHjnMo{dwjH=5v#%g`ev>_qj~FT z@_5nO8ij?&#$v`OYDG{#8w$iP-&u!Vg;Yn6_a;X(f&m> zDBm2Ptbr1qp#yf%fRyT0cEu*>n|qz>70<%?-ciDWv@P6 z;J$UlVujBzvHy{a+uztA1p$7ZUI7ruX4VvnNwjOz#*pDGw($ThC)k1(S*m-Lw4ru+7Fj*;BN0#8-lePS_ zX2$r%e#@k2Z4iB~PzPt8i6Drs1<(=>hOvByZ(Uv`gFxSw96f)etN|K+kE^(~hoqbF z;M1F@PJt4j&^hwYYmP>g}Expy<;kk*P!M5fZ$?Rptv7wud0k!z|=19<((j?h5$bguEg zx8RVhf|bsXR@k^doMdPuQs?W<3b#IjDx2;%=kxc<5Y)qOA=r}R^#Pn?{Z!hYn|2a%e?xH#|6orL&r)~e^?iPo$su)M z$%6z0IW!R$tAA7SEO_>nM5#iIAGUNZzvT?9{qIp}KvpOgVrIVntoa*G00!+a`tm$^ zzH(9Y4k&{4DS%+ElCKDY9F)rC;c!eHKlO^hRI_R|M0#O+30{*4Y@>+H0>zvC(2HVa+^A=o)@Zj%4@+nNOB zXcFsS#9W~+9wgccEV|d$)&Rs8$$}+9jJ@4sS{T{B4<#C@1bP~jZ?NFB=5 zjJGEzdS_OAG3q(VA~+k~)?nk}wrE%W+V-`?_-5zvN@lJeX#LGCfe+kj!Ne0G&wv*@ zik~sp8vYMrnI$}k&6>Zo`2dH_wM$K>N0QO(M~XL^wAhg-H!{-7p_hYMZPi5h+M8Yj zu2<(HM9`cg;MkslD^$9O0-iKKP}dVcpm!#b3kzi6FT(e=xhZm|DQ3*e?U5t@idd1&8vb#;^5b)UoJr{*1cHjQ_n6 zK{m<3eIe=gU3rTy}O`oPWj@QlB8BLs4uDZ-%&?L z*VO{Jfs#`;@tvh@0hsLrzN;NgZ6U^m*%Di$U6gV(r@U6taLGS=3T&fkgX!Oucb4xL z3YzwizScvL!M0i3Z+9<>iKN#ypNk~HP~({^FFl-sa$m-!1zb8{ESZ#Fui0$eZrdSH z2vBc))v3W}er|koB`XK&0>*^}ZVbUyVj!+)m z^qt?yaaua7gjmiQcC6jX@&;5Nr?1i7P;t2h)It5cM#rb#%Bc3xTo(DIj;|~R?D_eE z1G9ABv}7dr5>eU9^q-j(Y~fBS&XgvmVPJ-b6lm_6nspjU6kiHY^*KsJ3$3n!m(1N> z;)i;po_JY%_s)&~a%z32X2=VMiTpz(z*XUt5$yD606qdJ{kG8uzGo9!*X}GF2c}?T z!^z$7cufF1`Vzza1uE|t*PYu7gdZN|)g0qaZXg*Y*U;zX`Gh3=xCprgnba?Hmd+2A zZ*w)UCwyb$s8{*wi&1bV%%#qUd2U*}@{eyhtbZYEtCqf>;eUMT^Djw$a7fz^CJ)SK z$0c;Yg4MTH8_P3SyLs1e9Mi%9O(1{JIi!7hVZ3=0)wz9CGZyc2y9NMP;D(1xyhl zNM4%O z7CGqte&03JEa&VEN81Z_Jx5fIs>F_5UyFTxd$2Z=NS5^i>T!0(PZZByCzETvinOq< zAe0KaFCpm-*vzK+#)6+PDe+R8)4Gi6uavsL#NlEu>tjN|GpKXeufq?Id93B&7GoY| zN;HHHF4fmT5*zlN);~~3P?B{PaNg(J({*R*=8E@;iy>BHbJ~#Tq>@$PpC7KsLrs2u zvo-ts?qP2_l=|5I*K%GKg?;QfZ_~4CGMnKeThe9Dq3D?ZNmhKc=~@}r ztCD;vy^7o+PR-}WS*U*ct2?7F<$wCG8oaYDmpsaR4hwiSDn5!}`E*0T( zb*P1$84KMbKsKno$M+~RnaP6_ZR{zp(+rQPFCT(oV1m(ofo}^p;XP{LH~EI=krtz- z-}>>v%3Cw8%vhi9+?83LUC{17kVKCTENNgRAA3(w|{j{W${bmNW-5 zM|(=uhFy0^S;^vHmKtJKXXg&`4AJ;BW^iU)FpxRz>AKg!m{dVTc^(ak$+tB|rZhDn zKFZ@sHMP0pUnBO~zzjAJsuXgEcUjf8LRmRzU1NC41cc527cqMi0e;D7t0L|)Ef)=1 zJ32N&n-9L@tAWIQz)qbt(c7!8`^|yrGtIo>8d0N~T?C|{PxOUO3=h?VX(#~~eg6FD34g({{R zqo|C)1Vbb#=*Stwrzdz0i|X2ch}6F9Nj-DUiYvR3sn~bZd4@s%hkh# zrz*_hl^BGY;V*@pQ$z!E6m&kPG|}@fQs%+_lL(4gt`X>@0?dEs{QUS*VGD0H*_Ptp z4Ee8z{VKHm+$%VVAb%wBa)n(U4WN3+7p@FSpY&OV8=n`t#rJz$A8m?6lhNqxp~aJ$ z!<`;O!j%Vbr35^29RtaUi}TqmSXuTjHSqk6x7D z*}hdiFNm_!B#Sdeo9(~k*Rl4G*9w&H^pDjpTsT3~&Gnl;;uJ5`Fa}5n;gZ{z+`JFk zhnnwDXdnup1v`kAMdS=>mxswDc;GQA&EK~8#fsnT)!u9c+y{hdQkF`3RIhQ+&LY^&$5uili!i`cTRFF* zb{mP5S^s#1QW0june~<#*JhtUb9{xgNynEK)8-c{d!S{HZ~8K)kT9`uffw6`x&ZB) zsnteePC=$oNMjGk)QNFTU#P5`myTNS-`opPzKz{Tm5e9dwaT=~Z$g;`<8S>JQ54wt%YA7hS4XHQXA!lB?H z)ipI{37P5dM_dAHBR5OwvI-zNzf{k_%Om%1uLqZobOV%rvYD?;^-r~xs^CDiNR3(- zbc?)0o}hnv=g#yT8DteprRn~qD^QoMLMldWJZHKVwRF?Dr8pKMk5#h?JNhPfmhkx9 zsmv^2Kxep|C^GNf2+U-Iv7D%C%wuwWZHV+nA{tGiz>6B*e^BuO%{i7i7#UohjVmua zR)GB|)MLw_}CeR|kV7FDT?8pPs%!4a&Pe-8Yoz!mIGBNVA`T6o%W@LG z$n5bc%e5w3U9E_r@4JpX1sPtHM`KrB$J8A|{ z=YWH%$S1eonN;mYMxZrqH@0)4zEOxU;~`z+!BL=05Zn&B6h!@=h9PC1_U5`c*GUGyAypa6u+4_rW0%E0=>R+`PPwogWzMN;A3I3RO*HFhrUfv zd9!hJ8F+hjtAY})K6QKG%lHUbC38LsknmAgmb3gBoSF0ph#?hw8@Oy{i>de&Y}xOr zalvVMT_H+ETv}1V67~}7=-V)sCwiw}Qrit@GWsK&v>7%EMnU}1>Qg*AA=oX#)b5Oi z93Z<0+z)mQ2@G-gpfdA2Bp7gT{;9Wn9jF|7oq-Fo{DXG&RX-?|pb8KIRDmz8u_VCd zTPh^Z2ld4OHsast_t#Hc@h3}z&&EqLtCg*uKCy0w(^?Oa^NeY0tY8TcO(PZ3$^YUR zEI`Wan^xJ9z2|bN~Y;=POI-QPpA_lMucdw@`B{&diTu=uMeJ^OJ`^q+D z>hU%zkVYh(earm!;(n9V@S~4a@76mwF1qfu%Qw>0angq(A3{B@10bLquJLoyy$1h- zT2^n-P`hB);e&$q^!%+S`F96J0*flA(ZWj2m_QoB%oaDeCb}yGVsLa97!XTnrWYWY z`QgKx0hd5XxE+OwY3td(=hSol&euL@-d_HeO_CD9BJo&po^2+W{EJQMC>QI#-{bii zKN&0y!A@)NHeuMFD80TPF_ifz7YqbN{8*UOv$4(mdgzAEYaP4YS0XC0?oPyWvG+>5J`|%<=u5#5?|%iAEZW81 zQaR;LgDFaCW;{=^TYOez{a4}n%ok=l(BES|%3(}gmri@=ZteNkBBQF1_fsGD*)9n0 z$L+e8k=^#X>|@P-{w3b0HxJAgb8m&*gM3`%&M|7Ss$O)W&o-we>txK;{*fanjf6Tu!gUfBdIe3j<(|NNrhpN)>%p<%-k2 zxP2aqeWu+lCKVmL7Ear5x>sHlI~$<&)A_d0>Lo47_v;SBNdWs`N&dOb2Z$&+_vd;U zV(D+a^Tg5(ODcA3$$~N^a;J=oFJxlm0~*PP#h*&+)@03Ey5PuG(wm7^erdnN{AY2e z}m#JndLQQUQy{yG+lH!15$;|ff%7Hgl z=|NTY&N}H=>^D7YB(;~T)eOmwA2_JB^jz+1w$?}r#hT-J{~M1pp*m3;#B552?5BX_4rVDOe@Q)b7Qxt4F9TumP*xrQBp)PKGG7nvn-U zBkC-a%vpuY1`nBIEkSD%C{+HG#>Y4)^`2g2f8jvGzE+Ex**S6h?0>^&H^h=S^|-~WYMDUb)cjW(idu88`R6&X8uwZD zNxjGKYyf;MR6XoTE+5Lix=+E2hIk7nCweBn%bs)h{L#eDAFUezv|xsqs`p1yE?RFp zFfgNTEEL+@j{EZ8f!C_DjAekfga8EanxLRm+ktAU9Ymy^VtPl{6l{*O#Mm8<+F+Nn zFc9w9XF==6HI=0_O?)+lms(iAmoj0`pZz?*zL3q)P}tVcI;;U^SY+|xKd1k#P)L_N z((B#Hx7Sz0I+_6K|3eKz9&yVg#*uTxO@z`o8O}hcDmfZ`oFQ>U)X-q^x@A?hR?pI- zC$H+)ud|L%!23ocq!?7_wHJE7@XCvv((D#!so$*%5I>dBjng-d8W*q(z?&$X)a;o6uh1hfTmU3w z>|CF=5RNHI&M5M=Pr95kTAmj6wR_)uW0v%2Dcc$=*OhglW=EYH_66b^63ph>m5l7g z%=W$UFa_g|Oa8ZOzTfEu*zhHcV<%Px`2SrfwlTk^6mxsh{)>hEm*@8LU+sLs_Y%7O zDsyoOVfSr;&wUj+7`HC_q@suXKwgL{&c&1V%2k#3cGGW<;;`!Z69-?h_@GT~+!im6 zXOE%fnsjo!ap6p@I5VI=7pLqD;&Vx}d>5&cxkuLaA4y#d|n;1%_M^?1gIKmz8OUWPl{eblIetT;q z1jDuCru_QD$)3*kKn2ao(}`b|k6)FDpZ+h06M6wXVjMZeG%*!(Y_W2eVKF*-xt673 zI#=&h#;g3wiH!wRX7w5_kb-1>%?j%N|4vKQ1ij6jv|rNp4)Xc^#}RA) zOQRY*jvc**qI%%nn|%z^GR8{&2~qI6eQo;z8b={!De1e%@}1!YxPdPAzhAFfw;bjr5u(q1@cReTdFt+7zr#y>PGsS@-R>oGB>q=tVsm&^@KdE) zkyLINS|-_ z>U9scwH2+mFRrWqlla@ZTPlZ`;v+UHw0X%gG8jj^7&!Kx|tH=)oQC0 za8tL6JYi|Iy0Bv3mrcNTuZ;Z-wIVKf(<&RHr|+S0L%fRMqp%J0tSjOnL`B3Rzu&ie z{k#KN*%W?3nD?bzbN=^-%N0TQy${dVs|$BwUgrDlUb@Q!Rv?|pZqu+8SOi6k$RiVT zrGAZa_*XDLx%^~c6(l}s@u3uR&B>-wuqEsK2vwgCL5N!1`|W-BkC50sSn=ZY|6Zl* z9=uB9pK79WkQ(=YkH4v*Z1MVej)eVIMGa&OR5m&G&bFq4h*K)B680E+CLt@H^dTXn z_7DFBn-;Igs=qB9km9;cuNphA?&d{bR^F1x6$X;N!`BZ8=il(Ke0y08UgXR*hR{ac zldLZ7;hghmpwQXrOEr&HMl28lptiBS)!WXKi~~^2CzQuzE{Q%&`JU!qVCB^#uC`zW*)f7=;>1i7=p=HB(As^B&o7sInW{y$^~|n-ep{vzPsTrc+E$`pG5vefis2 znC_E#RSTB^FbL)|OnOv{{pGX)2Ugs@;XnOiyf=N9f5rkK%FwqE!bvIy06(Z%KZqU7 zy%E(i{R9ArEqz&baGwOPm=2xuU_;|n06CC9b@?V&@ZGxG8eMYUajR+w>=A&A49Cfz zYCDKLBmv4CY{pLtq;2Cm+RXSoM75q{p^^Q#kx^SR+(L#@!M zGM^p&9-s3daXcE(K2}p<8r54~-PFgeL8hSJ6}-ImBaRIK6;UV*Gq~dxP0>Pwz+xuM z3l=ay7e1$bdxah-3!Ez#B$^5?5soD-O%*rJ`k^rnhLeW6~Iae%T%5tyG^#s5< z%e!s?b2lEN(G@>IHkLk6*8~VvTu^Wi)&DPRM~T9FU^*x~-mk^`XC8fXCN8ECA^ zJi2r8qX!WZ?g^C#;V}RD9PbUe&A8N8P8|zz{v}ukCHsE!Mrota4%XO{_WhUaZ`nWbjgRR%100Ah`vtOxU#AHfV?mynyKtEYz>momCe!sYe~Kb*X;*! z%7%}A40JQ9crJi>3eIsi2YiZr-Yv&#E+Ys4+44C(uq4Jca}!(v63O2q zkBuEy4j2D$^Mgv<@2VMjdEN}nH!i*H&sh7SaFB{NZEQJT`!y+nM|Cd;!@|E8Ppyckl3@YdRvI(0*?xD13y1xK^#WxSBar@{ zHq>P*7y)%N=-z8uw{--Zz7b<>%>QWoW?19w5{A;E#Tcs$v8g2apgUZa>%i7~p2^C!z)b z70*-gOv5n+E*}Fe*)U+q-lxoRyqT;o&6L7_G+cswjR%k}7@_u)R(aVq{=~d57tF~8 zj2{#N6#hJNY={ATLk})=paDrpY)4Z0WCLp3&iVuxSxhD*=rLX}vU=$*OKI7#Z=HVY zq*jD({<6!+SLsL9(C-!w;8-Q@$a<-&RK=wM^o&+wtrW92x6ASCVZO8&&5_G1ou6p+ zKt`2>`_aT2DAoZG>Py}YQ+|LBaKAS}8V<7q32Y8fmm`qF5ib=0#e5TRtVNq4Vj!!6 zdB`v9fQNv(&%{G``uzLOLtmeBT!3S_XNqDl({A`A?Q5uU(Lv+u0d&9zjt0;&!THbu z94q9B<0k07ebBwIeMtduEHU&5+wAl8p#epTMrKf9T;N?7XE&S@EEhL&9mD`+`(_dK z7TUEUI&2RC`!&DelcYjWpnb}t`UxD(bnviTm_ zYp}%n$>L63cS3o7o%Q6!fEM9gu}43`t{RXw5R>Fc%{AMMQg|gBEak&H%au{E*2g1Y z$58&$mj&xP7$29evv&Q}J>X8v?fPk~5U=n0W4nnMf?6m@4MOzH85`-;akpu$R`d48A z#oeAd5nsFuAO(*c4GU@X7brpcO6n23?9cqz8Cg?X>vF~Ph2UITV-sQF1lhP>(!8Ae z`8Yy){L`gCwNZY?BLFN?^csguvoux<9>XT^OTPk0n8hFR>BXAV4j+KV$4w-bQ=<^;3oGyKmxHJ6@E#ZX_LA@q7w+SvaQjV?L`) zGGwr2QQG%IT|;`81YB)xe^Mksv7V3pR8@}vx#`kshoSk;2KpbsGG_;sL;YC@J~~|# zNqJhX(aXrvOzz})YE4BcLnGbyQLo;7DWPE|&Ep_Ymg6RP->>?>8_(V&dQ9rhm@l~H zHa@`rxyoD(#IW|STMLKISgXwdgt?d2EEyb1$EaNgPD2?z)|y(+g`kDB#l+2ZaHO|) zW9!&5%&bWcZ0C@1@?}))cTqEAt?4LC)bPbF9i5#p2v|fRk_)e7l5V2APc_Se0)9J> zLqq6%x|Sh^0FXeJQEU5;SnU3pvF*m0_A>xDJAZ8Mv>Sk!<^j5^&xwb0T2w!bJw9lpojUp#-+rcYZDMefmhjC6urcKp-Vgu=?F#N+zYV_XtI5#SLpDBq zbgY6-0R$#w=o8I81qBuX^RO#Tv?BE7YHy##ITSh|o(9j-x<3otNXO&{gePCY)4FzskW$=S8(c&j~C#W320Sz{eyn z=tE#VS|EeaW&kH(j0W`v6WH8$xD3Pqf)T;nAQc+`uzuW1g=}n8^Lc0#o};roJ^5m9 zSW!q}Wf0IFg9eZh+NC#^qa}o$t-x1AX8uw#z9*l?=hA@HtI-8T6~E#t*Oi-)z) z9_cXWXkvqODrIP<9*I2~`h@gKBgfbzd=c4nExJK>#avPFm%vE`fu>0pS>vbwYtuA) zM`JyBgcWQ^`>o;%ffF>J8;uf9a%#0oUu0d0>$61F?N%r{n(&Iner{C1;kZic$X#65 z5jeJDXiGasdj32zfCd6ZR#!DPUL~7aI!wbJjJL>!c-%+-s`2)#$+W))5qt1!dppR3 z2FzmXw5Ffm-aCkesDYW-%A2Z0AjK?YZHNlA;drAxinJ&Ks3p}wuim}$+H6?O4={Iq zZQIE2dI*Fj5;UYCsew?c^&=|Wl(IMdV(+TKiF=Ai=e%lfI?~aN4LvsV?p_IRFGB<) zn2NT8=vCdieZ*vd7RIW51j6aBa96op!-+AxvU4OD0b0j8e ztE7+pBkb*;DT%M2OT6eE29=^hesO&8l$+??(^xCSd*t&lQHd|3>ir-Ex9O&4|0-_$ zhOpPSze*1p)rgHiA(AKXL>ltI>R6i6CK1-Wj7XbirATc~^>`nHRE$~L_wkJj9!J|5 zi}OP=n@1;dSaqhJ6tiX1n=nN5&tb^c$iZ`*sFCv4P=k@Srt**EyN=7#6ejrPJA3@3 zehie;^jJbihcFwKwv{LOVd-_{gm%-PVXU}QFPWo+_s`R(wd=#@8Zu?7UnB6{WEYS|XrlM=kE;i?U)qJ0^9vzb4iTEl*xy2tudH*WtSf69PV ze?e`ffT}IYVyoS5`lhmpmB5IU5u|jh*NfQXp0P%Va(RYH<_vaJ7?S)+kS1|zmm^y> zjr@}Gx!Am{@rl42&yN;@>kUU|uPfri|2d*}D-BDczp?!G6xV;SfvuJilK2`FIpJDy z&Nzh){ywSvNp>m0ybrQVR>>ellRCa(3@1S2bi3+sz`y(@_|L`)az^GKI|J6QbZDG* z(p-~tiL^YYvXLon-f&hAf5Xi49Z+Q@+&Ycjp6A?!=FOhhJrk$KfrfIPu_Rjs9v-Kc z;x{BLOmUD7>LQ{1iM@_&zie8RP9;fC(9KU{i|%O)BNy$@odl3xE_u?_W!X{}J7qv- z@E0EIj`q+-yey!m+@h%-1*!w7Z!e3y#$nXcV$zgxa!+J#g*xSZNE2n8^xLzB3N&#+ zO)VcEB+s}zILoR~C+)O*ox%-bwcXiy)Q^;Q{^uuM!UzYYs$T9Lqcs?T40zZss25?6 z{jD23ku|7V#OQB=1(I6iWK)~{^Sp0XK!VwSU)mR+>!X8p9K`BW#`CC2r!e~HuVQN#C5K0RyzFUO$gS(T`+P&7h z#TdyaUUJ{;*&+8z0s_|6Y)UEF8qX{-U0{xeB!$UwxYj2$M~z5it7q^B`;Ut!^fql* zOhO+gneWPf=q|(C1UkOyJ53YHl20kBl<}0r?z5P);h)1PEUSb-?CNbnOnslxLKa<# z07-m<(@qpo^;-3NfxjO>N%m7Nd1W(BaRVyM|#(miZ5Ze@%Mh} z)Lq}db@E)^*fRWLYzI{ryLWS+)z5BQ*oYkBVVjG3)JGLCNt4}2nvm#t}c%jdd7mLW2%EkA9bBvy_xO(Z^Rp&6s})TJHzHVe|@v#zCyHYTzqb zbc4B<99tBKP1Pt2lBjPs@IZ3%_Yf`hv$cMVaK5W{7kEOoXs=73gf7`)}%kBC>p&^ zR?-tBU9L@3Y7GypOI?I;%1)PSQMky5Ftvc3y&c0J!-U0D0NQj-EDaum_5}-3`V5-^$K_ zLArxaF-yi+qJsf%fTmvULjd&LLaHmz0e~gZ%tKiJ`LpTXp$~2s0S|yU-FZFMs+{nh z)p_}NYVNn3cJM>d4s;;rT<60eDI7w$NqgOrwdLX7wg^4u^$FlGa?ru^P8MD%8N&ne zYdzGzV4wL3TB!ZnAb&ZKaw5rb{;D>M|6uT0-bxNvnua6MTJ>RCmMc6u{yx82cB4A= zJAN}G+`4PuaXe$J2j;G3tGOS|x7;hf!xX$k{g96Qay-+!0UAxZ3EIp5E5FR?r0Sxy zk=;Nn&Tca}<%Q=e05a^O7{_V0gxp6SirKSIZIPIcx-GN|onU+~n@Wl?b3S^l8dR!S zf##Zn+Nd!L|7jm^|1s;CNMu~)a^tcqmSU>-TPT-R8_S)ZEW0W^IS*QH)wLMx7bnIp zfa2{PFBbSG*u0rzyidsZ!frSot|Cxb*ymS!;}amM{ncq*f+S>Qa2iy3=7NSW3`aTd z6rXsty~F}k;nTUFIor>U*&D1TOjjCCPJ>H&<~wv71m7C_ERK#jxD{*FGAX-QfUe~G zj7MniR9pu+zMZ9{+Z6d_mx?)Hp$(l}EIDhvE4E4RmP~Wo=dEU|BGAW`GII?_{%x%y3Ee z3z`NWSja{^al~x4=r!_Aaa}8}`YWWT+y?;v(KLRivapmi1is(q$FF%xTsMSYPSe&2 zR5`F~v$?Ch0Kt*OZYnQSF0>Pg(Gj9B7;#Bx*;$sUPdZD6vv;SUn`);nt9TLy!2ccP zJ9~=r@rNnf9A}+KCVjbo&@$VW3*N+TYq|NNI8XymiZ=O4D8YCR1c6|f z>)`2~C9LgOOBhbQT~21AGAgivB~!a6@DJvGb}rb@)aY(J@0e>Xe%Ng$^5tukiw3EG z7#Iy1TcokHU3&w^n>Y}@h2F+ZLpUqpj=sU1QSjP%5#|xhUGc2%pn8YF{xUtYQgCvO z)ogb8gzL3IcK~!46$sP#cD&zly+b0|CPuwbD44&;JNG>uf-q&skDy)JX}4^fZStp{ z-VR)z!+&S!t#Em63VSQm-9(XV_82d2l(T$9NhRzJqiC!?jjNCB3xHUOX|;MSq>ZXj zzIf%V)h|Z;Z13c4MDp7KC(IgW7A0gn8 zaBt<40_S=bbr_m=2{=0HFrw1t%es?X-Xqd6Jduw9``zNJzg}kQpTu3@j1`ZmyXStf z*<{~*hJloLXcZ6)@r3XHdGtjxUv;ac%r=^>G}DKDE@O4YYb{h`wq(A4)5fm`Nd#AN zfkxZA^Y-k~{M)cGzW$cUOLPD?t|P!2;Tb361bL8Br2pMp;o-YF&U{&7AU`PO^VSn$ zVL@n79W&zH;G>t2bgY~wIv@9zGzIQV(exKFK&4%)@8N&=UsW1BP9ByS(dw>rs@kcxTYq1I;t3hlt4m{S zVS&t2bQw_Xi?ghAWWLin_k}MYEy7(L$y(FYs8v5byrhebZ%(i#m(CF09S>R<-3Vhe z6nv=$-#)~U=dv()Cpve6Ge1iM%robGdX&>Z%)d|9SsXdeKGR6y?Egv+l31`4+5^Ux ztenw0bs1Abp<}laUyNw){>t95ytg}{A>BXuwAA4(GO&ADu^%hal)#0r+aP4pL8KyQ zvQPN1SK$BBk$pVDaC#(yYv>f;9G4!SXp#7Aqk*O0enHH}>iaT7JS|rDw9$CuLAh{9 zJPk|`q*g^|z*X@_?1?|Ggc83AQ}qJH;;)g|4ukA!`tNO%tVPm%E#fkqau zN=WBs1mifVf!VSW)o>gIcB1|t66mLfSWC`X^XL1gjnJRy(-JxCkPt^H=ZfR0%pn7Y zyIMjK2iET|_M|2bW2uf*@S$Ry`cqd!2QLZ~@!A*7mHHM$mJ@Fl!!<~hjKHEpJ4!vn zfUbAyq~9jlalEE;wV&R5Oy9*39@oJH-1WN_<)5n!eKhn0%ZGP!(SmO2V^xaFh(Ww; z3b;tf$9lZRhx5iRMiVav+}Rw44qDv@Yy2Rw+aFwUI%>f zK8PX)*+2T7ZZey6hhDdL)R5OingTujE%Z^G8GT2F4?F0`so2fGxKbAeuJrtg30Jst zz1GFb11Pv6C%FzWsSI|6;4X$x?7#J}PE8`*OGWJM^Ar4eEo0#&0e{xU3Ic z;2i?ZHA8Q^?i)XCB7Rg1R zeChQy3M_q?Ire^}YE!x|{z17sk?d(SPqH?i>=;#!K2A+Bo9f`gWjrf|h>-RC5#bJLR zn=cIP6>|BZQSTrnMGlybQ@Ulpn(mt715ge7n-pOsgEJYIEyDqqZtXX)QvR5a_--Y9 zh01LjH7S?IM=II!rYSrD!l@AL7H}%V8HkOhl_U9d;q0cl@1m%1nva<>POL-$;v+B` z_hN35`r*EDxPUIz-%0hSK>mvoNxr>^Et%3bhc&R-eIE)ut9};9AUnZ9ml*j-6vo{&zNMP$!YIvkuWWyOdi3W`eTNXmIeN8D_D{_KHPr35w|;h^^uHOovJEgzcyj`)t->lFg!H%4 z6fwC{J6)3wDuWUTz02C4pz7-JqLn@W+%b;06_1%r0>f^ILT&k;H25C09KIEyxl$L~ z%Kh7LJQq)W1Zpc}y!y9g?>Lu;-R@-_He~8TYeWD5y7i*&*{U9Olm{s5Kn_xNrC(nP z#$Z8JUhzw;b>u@%R^A=AouA_ue<^G8^sU-=l+1D^wq4p-r+5xcnAMrBAJ<3jIlEXb zY0mpAO$(alMr)6=MXbGR^CA&QARu%uv_y{PD<8v7AjCxD!wx>LPgO_TCx{80`}Z?W zJ&LK;tM|+C*Up!YTW=+$stP{k4#q~Rh@41keJEsWeTri$t)lM*UGBOLh z!hqWR{|b}rlL_~O-zxKCIboF=--rniqW&z{_O|NSyvCoSspY?AoW9n+I&i-*N;|Yz z6mI}}^RSr@L}-P%pDcPc!~P;?=bf-iaF-Kze6zU*PNf6kph4Ge6HsY8>dJug*p<5A zb0h7!Zvz7@gS_p2Zv9iqzJI!q`y`}>Ee@_XKGWTejyd*?z1km*=?}lW95}UDNT_wI3A(@3Zs}tCW#`S>YTZ`L4KF!{RA4*@SJ>hNN&qjg&2Cq3)~IdZ zZ%1cp$IP9KA4mkGY^@Z>lrGcI63(nzKY$e`q4243S317&wqg3DiP4-qT^Ah~GH^Xi zeY~#-Av}MJU%uM|VJ49s+`m&!yS)tzP`<}aA=`$qk1i3pB@FaXqlHFqFj%uf=^!EP z8!VGHt1%J|0MHD(^S*v)s*E3@$QRi?2x?`SK$meQ2Dd^O;xD6UK4t^wZL)3u1{5TbFg5c0esgpsijQ22pF;Hg>nw1aHYHn#a;Zi7DWvz+=QUQ@%R-o4h# zf?jc2D&~`5?QYr(WsWx*>Vt_w?Uh?%Bqt+&6Hm4}Ht$j%0No~^VB4*q=d2amt^XoT z4nPqnXQfeES|^<=AK19kb^kB>jIWJ$Lw2WV!0s10&1_L;0?n1)EWL4`3IA2c!6HnP zKJBI@WM`9B;@J7R+t))&H?1t;v41L47H`#MfBmjz$e-;lMouq1HoLc54{mJdc`Atj ztZSYE$DMe7VI^7yYC;Wdg4zyZ+D#v{BQ3WXU;GrA{NiJl&=1NYt{EjKwjY9mopU}L zQP8npa9Qc!ehpBr9O^MCtjuwAYa6dTChm9dwt-4%NtA;_%Zr`HR=!p7|KdomJx#2=auF{pT`F< zL(thY_Si=Zi2M*UwSHfZWt^|laCuDMxURf;fe`h9J5cN8B1Ghzov6zO-bU0^KF5>2 z)vjaZM)H~TSSyYV<~n4>JjLvwuh;7@h7EHKDCP<|RN>1?{HgBQ+g{D;c#1cT7c)Ff z0ZjDN5ClBO+p;^{fnGl6Cmp_T#adLbRuN>fwTh6k$#AA+?iKXh4-{tSO_;ahuLjwk{J?UAn6v5R7V@}z7e$f2#=III~4V0EdnXx<2cor_dmpJ`ytg0e!W2eQ` zePNXgUG_V&PPb#MT`>A#6wmUR(3N@$;W=O`rL3rADqvP!Fp;a$-V_-Tu#Ok)VwcJB zJUG&%j+LLU-=8x1`Pgu-Lx6VN%rLaP=RZ<>*+W&NZwhdOwxnkn5u)qnlg$EK^w#Oi z|J3tF-o?bO$y|OCsrUCeT)(UwAOdr6zz~F;HHG5$7tG@Kn7%ieK26L#1@}QuGy*KT z1G7(Z!T64D15Dy`T`$BgsKigCJgltf-R;TYdWP(jsB)+9;>o)RmIbm;*uDmp`_A*t zjt0e&h3Tc~p+o9!doCZv(vJMWlv!7bojIdLnst#2gC;r1WU9_lI1Zn_QeMxf+JVH8 zO5DVKuV7$+CtSDqzWkc<7rQl=%l;oh^?Okf+k z5=gm>_KN`@ziIl=YFN%a5v}7@+=v)URMVu8i7>8rZ z3%ryQS`H(F|W+luISAQY(`KA-6Kz$Axwe>fNpS2V0JgR1pMdn*Ml zlH3p8-i>IH4v`h@O=O4=rB30;)E8;n=N`+@KpP{~NVezsI~m&w=>2G@39mjS*<^sj zrk^5=?RC4Cu-IYb{_8f`j~{}6K;6V0`;!dvK|M8eddgRl05EP74!eQ}UK?QmGVC1l z41O?#TdF~yrVF(~@~yuiSU?x~$;ni^JbbGThNhIXve=ctSN!UhApjt7WoBeppKxoH z(@XZ<;~3CFoh!~!hp`WK^xp29+wrd$7^M{L_*bJf4S>g5@18r%kDf@#I#l7Nkeyl>exNkbWf3H}L)v6FCBU6a5Wb%=FWo&eGoqhcyt_X8$HQ0V{;i7T~beadXc zvkf^fZ|PZ>DX)|XL%Ox{Ktt`fYGJ3?w*wQ&-@H!`&smg81!W9#6UAMJ3=Z;%PhgX# zPcT^O=MuVqJ-w2Od+pH|i+p?eMgZYh`cRqluf$EJ@c3y%k{b3d?e&9&H{_k$?^Ghs zAE`j3uHu#uk9DF1c8Kt2tG!|h$WCCteZPkcoIKTlapBOJZZ^Knj(IquX0k0%mFD?y zi`g{o|JB@Dp9tH5vJO3SrDon^%0~Z*%8f8hH{N`=RZY|v4~=^>E~R@4+Bln78U2uc zLfXWncCV@Dd`CH(b`dOqyOO$+fzX69D#S?l@55C)0Xjx`AS5yX*@`{S!d$OL_FWSVN+U#T z0{8D8oX<%x<9=Q7FpcVxQchX@R!;D>7@u6!HD~VkQ9L12i<6_AeYq^io4xh-;<`|7 z@F0_dz-Tw&D-z}xba%(1W!Y9D|1TB{qR>oB|DA%Yrkp@aizxlcvgg1A7zmCF2}xlI zGtpt_wWO2!C*Snnv!DDsd(D5(9yt!#^18e(>?IxMB?f_6WB0e|5N7dzw|~0@!@dT0 z6YxOQF9H#wOv47}x~0B*DI8V{IyB%-$o_4`Ozm&?LMV1{y-H>MpCgL?=ZMrl)jD-C zHX3g}L)8a)(g9E!fIWuOXvUnMC~0VE zY5NuxrZ*ayJ}AH7YVt1b=+b}U)V_TVI@u5fmNW^&75!ak8VCoW3y%o6aXYkp_aN|O zBTM4RC!HF)vp{;(Xm0u_%8qb3SL*E88IP~eXsOi_G_!@%Bv{Q(ag6sA_>h~3pDL{e zNz=O&7dl=nZAg9apEom;2dt)b$p?aZigFw_2+^0E-&7_{QvYy=jQf}y1JYdD*X_>L zm_U1?Nm9@3d76DRi$|YJ7>*qA_FTZHB)reaBPyKy#`IrEtFS5c*5-!DVQS(jb%t@8 zHYR1Pugn-|&C|gS_)z$pCiy^gb(U5MG>DUd$%$f{kzQLIudsIocZMS5WqrRI#bneF-TQpf+)Ug&OCy%7*#zj4VMrBKrxB@dzxIvVd69sgfG?z8Mnsy zG&Hw7$qv-Kk)0bjvPS7qwWU$_C)W-7Pnb?|-`=Qpza8Ya8Qp!{LxbePS>V?@zL4W z*ulHfpJ$2wAStwdLN#t{3J}5CyR2exNj~K6@$x+VN}|IUGU~I^3>2c=o8N=aq1~~_ zN8(U#hA_iEw!urcUdcmACZZvEO;Xf;zO!%^Usj&f)5~ru`DB$P2;(n0y!0=kE~UHF z1X}?==C*@s%Q{mOx(mBYYhm~68-8rn_~V?0ZK}!yA?kz?B;gm}&|U3uk_lA16)+5R>j$eXBduCq=#^CZu3s)Y=Lz!nQC6Hea3aUnYWj8-klY;oNWGkU81t+bOoV!`w>WdMdv?->mArU(H_K;Y*@_ zmc-4Q(jP5}_Ddqq?(eK@gL>!UopjL%Lw4`tW<0lBM0D_DDDC3paOAZaFwA2db1*fa z&&Q;gjLNZ3=-&svdRoT>Ft-vv!2L{QZb#i6H9$(3P$;U(K|!&VAL?lA~YHJoGX}g(?%F_}CUr6<94^1NF^bDOk;3 zkLGLr2TU>_ynK8&(9{~lWwzEb&JPk2a3|GLzD5g#+6gG4y(?U<$`2!b= zI(&e3XZpaT1gQ>}2ZMJ*7>))Ej~YABBY*wg!h4DkTq*ke#^kC0DP7vpD-8W%Xe@d& zC~pi7&LaH)+vfp`3e)TcS$Us`H=}Vv8p$QS!w?69FBR)PM97%pCUajeN|0<+9&o;z zI&ZEzv==#rSrACau`|%D!VDp^0#N+TOh`WxfT{zy(SGIK)zg2PqOrR+UC#-%zm9r2 zt%;C(lohaw2GN9m3pSiA-S;yo@f58*Cpg51K!)9eVvGQS_Egm=?`CY_Lj*apXKH6r#|!md>O}PPjUCBVj7F z7(zVO%Z2rHJzCNDG{r~^ku;#8NmN}wH#MBx-IBx1Jgru5mDYN4y*6%RpK9t(D zB!vmhD3P1ys!=)2U7|=a^vSWcFe1dFaB$Gc_xe13e_oI4{l4C>_v_~Qe!Z^8>-Bp7 z91izWNlbbni(Dz5n%v{ANT%5Y(9M=~N36XgFKCTPv+ZHCNwfWmaaQaSN^?4N%Of7rD4Jymel zzOl4=P-SR@9g{42sEeg8BOYQ`O$>LI``hZgllSEQ-ui_Qe!Tfa%`xlN+=->|r!J4B zjZ2Zx&b+VbP3jFh{AKP@qA{b@UI|uEb-skVObkEdC z4qG^ugVCp(**zf6_$e(-7vB(QSu|};LmcCowfE(zI-mR{wb1X!c*stn;%7&8- z+gD$2h|jX{k5&L;8_|hG7WH9RL~WJCO|o*kZSe57>0@@Da2QIPSF&@%`aWAZg0)`@uD#mf7VvH5ulc)Qle|;keK^N=^;?t}HE*1&KX$NFSgT|NecFGv zkUE_;JqC+3U=}y~6YWHO$5MC3T%!Nn^LSF&OtnZ`J-go`NwJgXK6BHlL#W+6B{}+X zDN6D66+$nnOStrYYTMfVCEC7SCWlWz+yr0>;Q^oJ|A@B6_X@8Xjb~7=Cn2D}Tz|!H z*beDGvj6OHRPNw-a@*mlxv69pwQEI3v^AzzSSyE2CCE|(X)T{`h?fUGKY9#GlAp3~ zh<_6sr4Uc`Dy3srhaH%L%kI!Bs`93nAMsKMrugBJ1`u<$E@MS~xpsqVJIK=s5K(Ef zzA?8&eZ6S7)>w7+JS_4acs@$eQQZ*V6e+5%yHOgm@tP%T(|@d2-viOZ=0a~t(qRW> zNUuv1ILf9=5Lr`bZhXJ&k7Pha@V|++(D!Ix3#kteKnwPzxTOufr3Rgmn-Nn*VC+RS zhk8AfW0w}v6Zb44@?OF0ACdPa{Fp`5>EXPk{UJ`l-%%yQty-@WFPcCjfo)4zQI_2x zWJT;J@|S2)-&z7Bk@{Yt%NdM!{Kg6nDe9Zl79?`cGE+v_vy8}l_(3#O2cc{+1kHIf zC3(9ej|eI1Dh4R<{jDM1HDKsk`^U(6AQYnW1cW#z(m378L&FZ(4i@<=vwU`?m#}2Y znSCq0nzVeCx>xpltW!#;LLJHMs(`nf(xMd@I33z$`ZIZTV?2%$K4g9Mca=|)c5#9~ zhmsGornx~hXvB63WBu=v>_?%YPA6j(iArPwF+Q?}6{Qdexce)u@-bh+V->(lMx;UZ z%S31txoi2eLI&NY5e#2I8y+TRQq7wn4Z?N!nhf%nZ92lsxMUdy`7c;u( zXhGpzL#iit&iedw3cHo{tTCPplV!=tPx(*etm|Tg#}~z0sS9~pAC|v2pm|!7)cHz! zI5 zYmjTbY&|%s#XzNna?J}J2#|U!LDW<2-cCW5V&x!fr*w@@u6-(zNY|z^#cuN#UW{w; zuB-A(9PmUPO2J$M@=MR@Ycp^;vKper^+XOzb`%{SemHRD3eWc-y*Pn_ z{GLEYTe`DL$bF(#=XP%3vv`%ZLXm7cu-=rCJv-cD85}ruoRo5p=|!Q}H)r}fPQmp0 z8C^$kI^QL_{Nv(;zb=2k5>=kStzC2|VvfuV>uk!xNf@`ft|mTG9142p>zLrXToH21 zT@@s9K(!DjH4+giTknp-8J65-9ySVzC=Ih6WN=U=W@&E|c>GTmhS5naNsjXv=ca@z z-?JGa9NI`B1fA2>T!>R)Rk2o%vTT@`ZUsYT1cLLf3zY06;$ifwxClJ=79SedVo5$C zQ`il5GOj66YPFz65P(sE=E7@dx??q?CYccjQ{@Y1PyAhfC*HiIyRv}qS@|*;K*Gg| z>MRaer}Yv(jmT15@}Uzd_I^`c)n@-b8FiNScs~KNL%XT|{yiC}Q}nqCtD}^m;qsnm`$&oHTZ3{?Eb~a%C1(ok~gvF5Ia_XnSC9c8W!J8rIM74&(9Zu@hG+7T!A*CT)2NK(LyUHJ=lawKU4 z)*=FAOc%L#79Ov=dFG!(FE0JFGCE)JGG4ViuqEeS?!emOXZ|72mmgsc{zGGR`wE+z z>z-)_jIIwopRU>U2w6p{rF0_Se1NJu03T#lUyof#(}zyW|X$)0NIGRjx=%#&wT2 zW8iy2UXWF34L_;&h90n9*lSGCYA9v)pVfvELVF*w%$e6fa) zep*m0UH`o@b@`dbJ;e`E&GgdGr2{v#0J0^PY%ZraIp0D#)3el3@H90N&>13tZkVEC ztqqywU|9Wk_HWNO*_UQce*0}>+o6m9UZJI`MgM4UpFKSgM)F6uya=x?dmjts8l$q$ za(&LGohAnS9N5?;zW(E%*Q|HCXND%>X)Gw?Rs{^M$D!IZYKKm4Z)1JXAoXoYR1!!3 z&nE3lW-ncQaews6&&_{FcQwzqCYwjmQe#pR7X3B`3T&Y8^tyf(*$Nu_x>0*n)wRfG5=M~OGQbopN9Nuu)8`eqF9C# zF}rN6i~h&?Hw-W7`F~$*Zce}0nD4*5y6F4!^Rb0fV@=ATdUqy0=2!ElW#{(Zzx?@2 z>Y84QZ_L8jnVc%|%ZPuLMxVFzTsUxlVRQ3Gw(_!)L@l0B9zmvgi zp&c`X1Yp^!^0tH-iF9s=A>Q)u-z!H)TP{u9zr1$7_2QcTmBq=CKHe6o$^S#dH1D6u zNcj1~gM}BXj}KfL`40g2E4VicND?eq7=9Z!PX`^h`qv?>z0;eWXp`X7**dY-*n8115jSU~^wVl(%? zd}f1p^2f@g&x<6fu4xFyC)9Wkrwrhd^-4(HjIPe32|w(z?GSgFdpcNlmfGi%9zkdR z?S<17A>g68q6?#|TkfZH-oLU;t?vJKVUBlYKK4a%_U6dmZwo7z1GW)@FJ5i#XD8o$ z2wXhe@oj$tLR0l{%b`DyMbeZ~{dvr7=g69;fPx2gkHfqG;&UPFFYGns+23lQ)OL9^ z>`e9L%feaj-Gc>R@G`UY1JZFT-aymOncbaHTBw)Vm*?H9nLp+`~|hDn_@GsK}J`8NN{nJlNus%JC-b?gAxRpkE2em2%S^m z4;I(I^m2?2hkrEfBThScSrxq*(!OUwdK}r?pQc=JC>Hr>QU2SNACEUzH-}aez3TaOIW}U(G!>{-8H-5MfOPWu6J1ym zDlIW@tV&FF3O5EZT$V7bUc<+xs6*_gMW6=MdRv+&D#GhQu`#pRDW9NU5wgOa?bg83 z>T&sY`9O)q>AITb=^k{J`qgxN{(I0RzNLx<J1<$)(P$+vYh-La^r1yQqAA za61V1*p3UbJ!Nml#FCe7nA2oo<`K;8oVMtD%&|+4;yN)zTwta8U42x^7kId$rwGKk zC0Ss?>Lavw6oM|RhVr9Dxed^E6sk? zXR>P1wBT0Q0rkP^rq9u=^P+W!J&dEFbdHwelRql7>s3BQ*VBkx(&TJ8dFvTCpDQZcL(77?rBdpAo)*21SJ> zD(X*CD!~U4p%%y_qIW29?<@a#qUkF$G`iit8elzOc~!vE1r`9-if6&Xt0|`$#luK} zEn4|@SOgPfbj5GnNu=jOEmTdoxSQ*W_jj3urBghE# z2ukRbJxYhA&<2m<;vSN#ri9gAXcZzFt<*KrGHAoZkt!AuS&mqV8@p>bVOga^V?q=C8$5VUWv8yHHRW@Ivr0M)6^#59AJqs~{bZ&@{E|EzAO^8j|{1A*(OeobM^n({zwP8nQ4h8+PYXk+5nFTsYP^*EBR%4730 zA+~5#rPY9xQ46CR#x(Ych!Bl?;*$DS3##k1OqJj_eSUGQUPjQx3`%WuWVKD((0_Y( zs2dQJvaI+RfLNuT=!j>6USgNjhDN$EaZ;1D)kc3Ngoq8ix;NQDkFrnOFlk67SPwzU zxDKPp&7%A}xLX5R%2sNq(oV7#qKpOZM!nZ{Q>CB*91bAvQ!2nwbz>>{#!;7U@=6pp$;<2$Gx2>#K}w+KC0C`+~RhtQ^uAL=|Cy6jWrMC z&Ya+D62=;UiYxs{bPbwNnxh)JKw>N zO_15BAhFIcsPZNw=vTEto`@FRKha0k!hy|RAMGq{u*%2>a9@jxy;@_LFj~HzWts9} zvJ4o1``V#;N%OJL8n~wHHq%pEALTzyl{Qupd7^gfB9Wd8Sg@4&xbae<-FqoR@4vcl z#0Y^bZ%Jr1Aftdn&Xa6wt=PuYKRb0%V?i9P@c(JWn@A)*IKvZT85?^(4|JC$pZkE#o@jT9l{Dul<-owr@=d@dg*h zVlK39f!s;(jRpiD^%J{mYkP}IMYq0B zK8#{n(W7J-4{79x3HIdTRH7t7T;SbzE2-WlWK(&At$OPG7@t z%y>5+H~JT!5Dfd5=7s>TX4J-G_(eDQ-VQ0z8mRb=2}U!%Q_MYm#PV=DZt{|2cMOm3#?Wsw8p-PrX&poJ_11709>HiwDboZLI}#E0fxEko+tJg_^my1#l~9={y?^;|?V9!*T+3!N$O zg5wMR)g*M51>l@@tWcA%p_&c9o7G+9NrCM~F}qYe>L;>bu9P~L?9*D^1F#a$cnGJ} zNJ;A9ibcd5Mlq4tzFov*ff)1CopHUdAx1890Py7+V*Yv49M2nX<96>`&>QdK*5Ix> z+s<0{5tun%s-X&S7^NA6rAj}2Z`%Ab6$sPv?MnRgv7Z}^Yv(=nMbYSFRY&;~$w^Ce>jyYD zpe&Ik%z%C-Vzvt49r}=W(;c$JAK!8uhsrHsJW`dp6y z>ph7+Ld{rz!&6yvA)&Ou7H5xD^0>;Nh z`x{Q?)YJA1i_X_q@7ak3>E|2{=d@Kj8@W(!Y_GZrsCfAH^u82$Xw_$jjSFjVQ8pXy zD!<}Luyz+Bg*QrqF^>zI48d|m;SI@|9kqe zgR}J(N{r@TuCZor0{M1n@Dg>{-Gf`%r8?VYly3_`Jj|7*cfi;@PReFRKA}n_;SspW6;0G20|ubqJV9F4np?BBUyN7= z!k?R0mWxppxN|)$9Oahx?;AUxc-O)zGD_1mJ;H}xtx1YN5H|%Y_k`h&B!7v0A=Z6FLX%C7F0@rGWVd{VG|IT>7lBax!X}o2q86;2AsC5u4;&_ zSvt1kYG-_R|9cGv;G&6-(xs>)&X86{MaSb)>Fp#~I3 zE&Y)uMsVJbr|%#+eG>39-sW*9y16KwlN5wMZbprdd*2C_UBfOCpqG^IT-{B3&o^(RjQLa=|1UjjAs>mqhz3k-Qa|%omW#q!%koMw!_N&7J) zB?;-$XGgJpSJ%JAsZ*P**e{VX3`;+5)nGZ($#Sqo=)GPzH-%4VWxop*v`%GG79iY= zcj|)RS?6T>(V%+#m{3jp`s>Wot`WxLYRs?mEo{gFAyXEi*1N^0Rh-cUnXGDHeGv*5 z9v$E|zRz#9x)ox~Q_R{Q)-}daWW+CUYnp~xx>orCA_YDJmo=p#(O|d4H|^-2sMhHQ z;}VqNSY~Jor8h(YCz_m*X#}#|&8$oDOuknRa**-P!-|zs3lC=Uz4y}RTYkx*heLyZx@yW zg}w13mm=i=ftW9>VeDh3BpLha<tGtno*n9{}msz;g)Ju1Q*ne43D(BPafwO zPsQ{S;-t^!lD7!b~%8Nw}Ct z>sl@ts=nvl&ak`hIJ&;*#`Xk?N5r$NcNO9y(DN)64Py&*`X~L0kgiqm+r2*5wZZhc z2JKqdDQU9tzOmz-@LB`uFRVbEUM-}2u+stZHZK_28FqU=jMDbX!%NCw@8oq&*;glN ze-bA7rzfmr9L{3G< zbwGi2UW8OU3GXsVxIXfH>gz%T&IFiQwCAe2!<@;)g`K)Hr>t;rtEV`Xvc*clGuS&_ SKq1@?%rrAPr~!KI0PueY{6#GQ literal 0 HcmV?d00001 diff --git a/apps/remix/app/components/dialogs/claim-create-dialog.tsx b/apps/remix/app/components/dialogs/claim-create-dialog.tsx index c3564e7e0..589484693 100644 --- a/apps/remix/app/components/dialogs/claim-create-dialog.tsx +++ b/apps/remix/app/components/dialogs/claim-create-dialog.tsx @@ -3,6 +3,7 @@ import { useState } from 'react'; import { Trans, useLingui } from '@lingui/react/macro'; import type { z } from 'zod'; +import type { TLicenseClaim } from '@documenso/lib/types/license'; import { generateDefaultSubscriptionClaim } from '@documenso/lib/utils/organisations-claims'; import { trpc } from '@documenso/trpc/react'; import type { ZCreateSubscriptionClaimRequestSchema } from '@documenso/trpc/server/admin-router/create-subscription-claim.types'; @@ -22,7 +23,11 @@ import { SubscriptionClaimForm } from '../forms/subscription-claim-form'; export type CreateClaimFormValues = z.infer; -export const ClaimCreateDialog = () => { +type ClaimCreateDialogProps = { + licenseFlags?: TLicenseClaim; +}; + +export const ClaimCreateDialog = ({ licenseFlags }: ClaimCreateDialogProps) => { const { t } = useLingui(); const { toast } = useToast(); @@ -67,6 +72,7 @@ export const ClaimCreateDialog = () => { ...generateDefaultSubscriptionClaim(), }} onFormSubmit={createClaim} + licenseFlags={licenseFlags} formSubmitTrigger={ + + + Sync license from server + + + + ); +}; diff --git a/apps/remix/app/components/general/admin-license-status-banner.tsx b/apps/remix/app/components/general/admin-license-status-banner.tsx new file mode 100644 index 000000000..690f0bdfd --- /dev/null +++ b/apps/remix/app/components/general/admin-license-status-banner.tsx @@ -0,0 +1,78 @@ +import { Trans } from '@lingui/react/macro'; +import { AlertTriangleIcon, KeyRoundIcon } from 'lucide-react'; +import { Link } from 'react-router'; +import { match } from 'ts-pattern'; + +import type { TCachedLicense } from '@documenso/lib/types/license'; +import { cn } from '@documenso/ui/lib/utils'; +import { Button } from '@documenso/ui/primitives/button'; + +export type AdminLicenseStatusBannerProps = { + license: TCachedLicense | null; +}; + +export const AdminLicenseStatusBanner = ({ license }: AdminLicenseStatusBannerProps) => { + const licenseStatus = license?.derivedStatus; + + if (!license || licenseStatus === 'ACTIVE') { + return null; + } + + return ( +
+
+
+ + + {match(licenseStatus) + .with('PAST_DUE', () => ( + + License Payment Overdue - Please update your payment to avoid service disruptions. + + )) + .with('EXPIRED', () => ( + + License Expired - Please renew your license to continue using enterprise features. + + )) + .with('UNAUTHORIZED', () => + license ? ( + + Invalid License Type - Your Documenso instance is using features that are not part + of your license. + + ) : ( + + Missing License - Your Documenso instance is using features that require a + license. + + ), + ) + .otherwise(() => null)} +
+ + +
+
+ ); +}; diff --git a/apps/remix/app/components/general/metric-card.tsx b/apps/remix/app/components/general/metric-card.tsx index 67ecf17aa..559b049f9 100644 --- a/apps/remix/app/components/general/metric-card.tsx +++ b/apps/remix/app/components/general/metric-card.tsx @@ -5,15 +5,16 @@ import { cn } from '@documenso/ui/lib/utils'; export type CardMetricProps = { icon?: LucideIcon; title: string; - value: string | number; + value?: string | number; className?: string; + children?: React.ReactNode; }; -export const CardMetric = ({ icon: Icon, title, value, className }: CardMetricProps) => { +export const CardMetric = ({ icon: Icon, title, value, className, children }: CardMetricProps) => { return (
@@ -21,7 +22,7 @@ export const CardMetric = ({ icon: Icon, title, value, className }: CardMetricPr
{Icon && (
- +
)} @@ -30,9 +31,11 @@ export const CardMetric = ({ icon: Icon, title, value, className }: CardMetricPr
-

- {typeof value === 'number' ? value.toLocaleString('en-US') : value} -

+ {children || ( +

+ {typeof value === 'number' ? value.toLocaleString('en-US') : value} +

+ )}
); diff --git a/apps/remix/app/components/tables/admin-claims-table.tsx b/apps/remix/app/components/tables/admin-claims-table.tsx index 425472d0a..cf6d82aac 100644 --- a/apps/remix/app/components/tables/admin-claims-table.tsx +++ b/apps/remix/app/components/tables/admin-claims-table.tsx @@ -6,6 +6,7 @@ import { EditIcon, MoreHorizontalIcon, Trash2Icon } from 'lucide-react'; import { Link, useSearchParams } from 'react-router'; import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params'; +import type { TLicenseClaim } from '@documenso/lib/types/license'; import { ZUrlSearchParamsSchema } from '@documenso/lib/types/search-params'; import { SUBSCRIPTION_CLAIM_FEATURE_FLAGS } from '@documenso/lib/types/subscription'; import { trpc } from '@documenso/trpc/react'; @@ -27,7 +28,11 @@ import { useToast } from '@documenso/ui/primitives/use-toast'; import { ClaimDeleteDialog } from '../dialogs/claim-delete-dialog'; import { ClaimUpdateDialog } from '../dialogs/claim-update-dialog'; -export const AdminClaimsTable = () => { +type AdminClaimsTableProps = { + licenseFlags?: TLicenseClaim; +}; + +export const AdminClaimsTable = ({ licenseFlags }: AdminClaimsTableProps) => { const { t } = useLingui(); const { toast } = useToast(); @@ -97,11 +102,11 @@ export const AdminClaimsTable = () => { ); if (flags.length === 0) { - return

{t`None`}

; + return

{t`None`}

; } return ( -
    +
      {flags.map(({ key, label }) => (
    • {label}
    • ))} @@ -114,7 +119,7 @@ export const AdminClaimsTable = () => { cell: ({ row }) => ( - + @@ -124,6 +129,7 @@ export const AdminClaimsTable = () => { e.preventDefault()}>
      diff --git a/apps/remix/app/root.tsx b/apps/remix/app/root.tsx index 1b5ae1acd..76db197e0 100644 --- a/apps/remix/app/root.tsx +++ b/apps/remix/app/root.tsx @@ -7,7 +7,6 @@ import { data, isRouteErrorResponse, useLoaderData, - useLocation, } from 'react-router'; import { PreventFlashOnWrongTheme, ThemeProvider, useTheme } from 'remix-themes'; @@ -87,8 +86,6 @@ export async function loader({ request }: Route.LoaderArgs) { export function Layout({ children }: { children: React.ReactNode }) { const { theme } = useLoaderData() || {}; - const location = useLocation(); - return ( {children} @@ -129,6 +126,18 @@ export function LayoutContent({ children }: { children: React.ReactNode }) { + {/* Global license banner currently disabled. Need to wait until after a few releases. */} + {/* {licenseStatus === '?' && ( +
      +
      +
      + + This is an expired license instance of Documenso +
      +
      +
      + )} */} + diff --git a/apps/remix/app/routes/_authenticated+/admin+/_layout.tsx b/apps/remix/app/routes/_authenticated+/admin+/_layout.tsx index 33ef772f5..8757372b1 100644 --- a/apps/remix/app/routes/_authenticated+/admin+/_layout.tsx +++ b/apps/remix/app/routes/_authenticated+/admin+/_layout.tsx @@ -11,25 +11,37 @@ import { import { Link, Outlet, redirect, useLocation } from 'react-router'; import { getSession } from '@documenso/auth/server/lib/utils/get-session'; +import { LicenseClient } from '@documenso/lib/server-only/license/license-client'; import { isAdmin } from '@documenso/lib/utils/is-admin'; import { cn } from '@documenso/ui/lib/utils'; import { Button } from '@documenso/ui/primitives/button'; +import { AdminLicenseStatusBanner } from '~/components/general/admin-license-status-banner'; + import type { Route } from './+types/_layout'; export async function loader({ request }: Route.LoaderArgs) { const { user } = await getSession(request); + const license = await LicenseClient.getInstance()?.getCachedLicense(); + if (!user || !isAdmin(user)) { throw redirect('/'); } + + return { + license: license || null, + }; } -export default function AdminLayout() { +export default function AdminLayout({ loaderData }: Route.ComponentProps) { + const { license } = loaderData; const { pathname } = useLocation(); return (
      + +

      Admin Panel

      diff --git a/apps/remix/app/routes/_authenticated+/admin+/claims.tsx b/apps/remix/app/routes/_authenticated+/admin+/claims.tsx index c9ed9d2e0..2028116cb 100644 --- a/apps/remix/app/routes/_authenticated+/admin+/claims.tsx +++ b/apps/remix/app/routes/_authenticated+/admin+/claims.tsx @@ -4,13 +4,26 @@ import { useLingui } from '@lingui/react/macro'; import { useLocation, useSearchParams } from 'react-router'; import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value'; +import { LicenseClient } from '@documenso/lib/server-only/license/license-client'; import { Input } from '@documenso/ui/primitives/input'; import { ClaimCreateDialog } from '~/components/dialogs/claim-create-dialog'; import { SettingsHeader } from '~/components/general/settings-header'; import { AdminClaimsTable } from '~/components/tables/admin-claims-table'; -export default function Claims() { +import type { Route } from './+types/claims'; + +export async function loader() { + const licenseData = await LicenseClient.getInstance()?.getCachedLicense(); + + return { + licenseFlags: licenseData?.license?.flags, + }; +} + +export default function Claims({ loaderData }: Route.ComponentProps) { + const { licenseFlags } = loaderData; + const { t } = useLingui(); const [searchParams, setSearchParams] = useSearchParams(); @@ -47,7 +60,7 @@ export default function Claims() { subtitle={t`Manage all subscription claims`} hideDivider > - +
      @@ -58,7 +71,7 @@ export default function Claims() { className="mb-4" /> - +
      ); diff --git a/apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx b/apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx index 58a8449c7..159f9dd76 100644 --- a/apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx +++ b/apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx @@ -12,6 +12,8 @@ import type { z } from 'zod'; import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; import { SUBSCRIPTION_STATUS_MAP } from '@documenso/lib/constants/billing'; import { AppError } from '@documenso/lib/errors/app-error'; +import { LicenseClient } from '@documenso/lib/server-only/license/license-client'; +import type { TLicenseClaim } from '@documenso/lib/types/license'; import { SUBSCRIPTION_CLAIM_FEATURE_FLAGS } from '@documenso/lib/types/subscription'; import { trpc } from '@documenso/trpc/react'; import type { TGetAdminOrganisationResponse } from '@documenso/trpc/server/admin-router/get-admin-organisation.types'; @@ -40,7 +42,20 @@ import { SettingsHeader } from '~/components/general/settings-header'; import type { Route } from './+types/organisations.$id'; -export default function OrganisationGroupSettingsPage({ params }: Route.ComponentProps) { +export async function loader() { + const licenseData = await LicenseClient.getInstance()?.getCachedLicense(); + + return { + licenseFlags: licenseData?.license?.flags, + }; +} + +export default function OrganisationGroupSettingsPage({ + params, + loaderData, +}: Route.ComponentProps) { + const { licenseFlags } = loaderData; + const { t } = useLingui(); const { toast } = useToast(); @@ -129,7 +144,7 @@ export default function OrganisationGroupSettingsPage({ params }: Route.Componen if (isLoadingOrganisation) { return (
      - +
      ); } @@ -239,7 +254,7 @@ export default function OrganisationGroupSettingsPage({ params }: Route.Componen )} - +
      @@ -278,6 +293,7 @@ type TUpdateGenericOrganisationDataFormSchema = z.infer< type OrganisationAdminFormOptions = { organisation: TGetAdminOrganisationResponse; + licenseFlags?: TLicenseClaim; }; const GenericOrganisationAdminForm = ({ organisation }: OrganisationAdminFormOptions) => { @@ -349,7 +365,7 @@ const GenericOrganisationAdminForm = ({ organisation }: OrganisationAdminFormOpt {!form.formState.errors.url && ( - + {field.value ? ( `${NEXT_PUBLIC_WEBAPP_URL()}/o/${field.value}` ) : ( @@ -381,12 +397,17 @@ const ZUpdateOrganisationBillingFormSchema = ZUpdateAdminOrganisationRequestSche type TUpdateOrganisationBillingFormSchema = z.infer; -const OrganisationAdminForm = ({ organisation }: OrganisationAdminFormOptions) => { +const OrganisationAdminForm = ({ organisation, licenseFlags }: OrganisationAdminFormOptions) => { const { toast } = useToast(); const { t } = useLingui(); const { mutateAsync: updateOrganisation } = trpc.admin.organisation.update.useMutation(); + const hasRestrictedEnterpriseFeatures = Object.values(SUBSCRIPTION_CLAIM_FEATURE_FLAGS).some( + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + (flag) => flag.isEnterprise && !licenseFlags?.[flag.key as keyof TLicenseClaim], + ); + const form = useForm({ resolver: zodResolver(ZUpdateOrganisationBillingFormSchema), defaultValues: { @@ -440,7 +461,7 @@ const OrganisationAdminForm = ({ organisation }: OrganisationAdminFormOptions) = - +

      Inherited subscription claim @@ -493,7 +514,7 @@ const OrganisationAdminForm = ({ organisation }: OrganisationAdminFormOptions) = {`https://dashboard.stripe.com/customers/${field.value}`} @@ -582,34 +603,57 @@ const OrganisationAdminForm = ({ organisation }: OrganisationAdminFormOptions) =
      - {Object.values(SUBSCRIPTION_CLAIM_FEATURE_FLAGS).map(({ key, label }) => ( - ( - - -
      - + {Object.values(SUBSCRIPTION_CLAIM_FEATURE_FLAGS).map(({ key, label, isEnterprise }) => { + const isRestrictedFeature = + isEnterprise && !licenseFlags?.[key as keyof TLicenseClaim]; // eslint-disable-line @typescript-eslint/consistent-type-assertions - -
      -
      -
      - )} - /> - ))} + return ( + ( + + +
      + + + +
      +
      +
      + )} + /> + ); + })}
      + + {hasRestrictedEnterpriseFeatures && ( + + + ¹  + Your current license does not include these features.{' '} + + Learn more + + + + )}

      diff --git a/apps/remix/app/routes/_authenticated+/admin+/stats.tsx b/apps/remix/app/routes/_authenticated+/admin+/stats.tsx index bc43d1942..c99cfd0fa 100644 --- a/apps/remix/app/routes/_authenticated+/admin+/stats.tsx +++ b/apps/remix/app/routes/_authenticated+/admin+/stats.tsx @@ -23,8 +23,10 @@ import { getUserWithSignedDocumentMonthlyGrowth, getUsersCount, } from '@documenso/lib/server-only/admin/get-users-stats'; +import { LicenseClient } from '@documenso/lib/server-only/license/license-client'; import { getSignerConversionMonthly } from '@documenso/lib/server-only/user/get-signer-conversion'; +import { AdminLicenseCard } from '~/components/general/admin-license-card'; import { MonthlyActiveUsersChart } from '~/components/general/admin-monthly-active-user-charts'; import { AdminStatsSignerConversionChart } from '~/components/general/admin-stats-signer-conversion-chart'; import { AdminStatsUsersWithDocumentsChart } from '~/components/general/admin-stats-users-with-documents'; @@ -42,6 +44,7 @@ export async function loader() { signerConversionMonthly, monthlyUsersWithDocuments, monthlyActiveUsers, + licenseData, ] = await Promise.all([ getUsersCount(), getOrganisationsWithSubscriptionsCount(), @@ -50,6 +53,7 @@ export async function loader() { getSignerConversionMonthly(), getUserWithSignedDocumentMonthlyGrowth(), getMonthlyActiveUsers(), + LicenseClient.getInstance()?.getCachedLicense(), ]); return { @@ -60,6 +64,7 @@ export async function loader() { signerConversionMonthly, monthlyUsersWithDocuments, monthlyActiveUsers, + licenseData: licenseData || null, }; } @@ -74,6 +79,7 @@ export default function AdminStatsPage({ loaderData }: Route.ComponentProps) { signerConversionMonthly, monthlyUsersWithDocuments, monthlyActiveUsers, + licenseData, } = loaderData; return ( @@ -94,6 +100,10 @@ export default function AdminStatsPage({ loaderData }: Route.ComponentProps) {
      +
      + +
      +

      diff --git a/apps/remix/server/router.ts b/apps/remix/server/router.ts index bf091a955..12ac294ce 100644 --- a/apps/remix/server/router.ts +++ b/apps/remix/server/router.ts @@ -10,6 +10,7 @@ import { tsRestHonoApp } from '@documenso/api/hono'; import { auth } from '@documenso/auth/server'; import { API_V2_BETA_URL, API_V2_URL } from '@documenso/lib/constants/app'; import { jobsClient } from '@documenso/lib/jobs/client'; +import { LicenseClient } from '@documenso/lib/server-only/license/license-client'; import { TelemetryClient } from '@documenso/lib/server-only/telemetry/telemetry-client'; import { getIpAddress } from '@documenso/lib/universal/get-ip-address'; import { env } from '@documenso/lib/utils/env'; @@ -140,4 +141,7 @@ if (env('NODE_ENV') !== 'development') { void TelemetryClient.start(); } +// Start license client to verify license on startup. +void LicenseClient.start(); + export default app; diff --git a/packages/app-tests/e2e/license/enterprise-feature-restrictions.spec.ts b/packages/app-tests/e2e/license/enterprise-feature-restrictions.spec.ts new file mode 100644 index 000000000..a4ea41ffa --- /dev/null +++ b/packages/app-tests/e2e/license/enterprise-feature-restrictions.spec.ts @@ -0,0 +1,326 @@ +import { expect, test } from '@playwright/test'; +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import type { TCachedLicense, TLicenseClaim } from '@documenso/lib/types/license'; +import { seedUser } from '@documenso/prisma/seed/users'; + +import { apiSignin } from '../fixtures/authentication'; + +const LICENSE_FILE_NAME = '.documenso-license.json'; +const LICENSE_BACKUP_FILE_NAME = '.documenso-license-backup.json'; + +/** + * Get the path to the license file. + * + * The server reads from process.cwd() which is apps/remix when the dev server runs. + * Tests run from packages/app-tests, so we need to go up to the root then into apps/remix. + */ +const getLicenseFilePath = () => { + // From packages/app-tests/e2e/license -> ../../../../apps/remix/.documenso-license.json + return path.join(__dirname, '../../../../apps/remix', LICENSE_FILE_NAME); +}; + +/** + * Get the path to the backup license file. + */ +const getBackupLicenseFilePath = () => { + return path.join(__dirname, '../../../../apps/remix', LICENSE_BACKUP_FILE_NAME); +}; + +/** + * Backup the existing license file if it exists. + */ +const backupLicenseFile = async () => { + const licensePath = getLicenseFilePath(); + const backupPath = getBackupLicenseFilePath(); + + try { + await fs.access(licensePath); + await fs.rename(licensePath, backupPath); + } catch (e) { + // File doesn't exist, nothing to backup + console.log(e); + } +}; + +/** + * Restore the backup license file if it exists. + */ +const restoreLicenseFile = async () => { + const licensePath = getLicenseFilePath(); + const backupPath = getBackupLicenseFilePath(); + + try { + await fs.access(backupPath); + await fs.rename(backupPath, licensePath); + } catch (e) { + // Backup doesn't exist, nothing to restore + console.log(e); + } +}; + +/** + * Write a license file with the given data. + * Pass null to delete the license file. + */ +const writeLicenseFile = async (data: TCachedLicense | null) => { + const licensePath = getLicenseFilePath(); + + if (data === null) { + await fs.unlink(licensePath).catch(() => { + // File doesn't exist, ignore + }); + } else { + await fs.writeFile(licensePath, JSON.stringify(data, null, 2), 'utf-8'); + } +}; + +/** + * Create a mock license object with the given flags. + */ +const createMockLicenseWithFlags = (flags: TLicenseClaim): TCachedLicense => { + return { + lastChecked: new Date().toISOString(), + license: { + status: 'ACTIVE', + createdAt: new Date(), + name: 'Test License', + periodEnd: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // 30 days from now + cancelAtPeriodEnd: false, + licenseKey: 'test-license-key', + flags, + }, + requestedLicenseKey: 'test-license-key', + derivedStatus: 'ACTIVE', + unauthorizedFlagUsage: false, + }; +}; + +// Run tests serially to avoid race conditions with the license file +test.describe.configure({ mode: 'serial' }); + +// SKIPPING TEST UNTIL WE ADD A WAY TO OVERRIDE THE LICENSE FILE. +test.describe.skip('Enterprise Feature Restrictions', () => { + test.beforeAll(async () => { + // Backup any existing license file before running tests + await backupLicenseFile(); + }); + + test.afterAll(async () => { + // Restore the backup license file after all tests complete + await restoreLicenseFile(); + }); + + test.beforeEach(async () => { + // Clean up license file before each test to ensure clean state + await writeLicenseFile(null); + }); + + test.afterEach(async () => { + // Clean up license file after each test + await writeLicenseFile(null); + }); + + test('[ADMIN CLAIMS]: shows restricted features with asterisk when no license', async ({ + page, + }) => { + // Ensure no license file exists + await writeLicenseFile(null); + + const { user: adminUser } = await seedUser({ + isAdmin: true, + }); + + await apiSignin({ + page, + email: adminUser.email, + redirectPath: '/admin/claims', + }); + + // Click Create claim button to open the dialog + await page.getByRole('button', { name: 'Create claim' }).click(); + + // Wait for dialog to open + await expect(page.getByRole('dialog')).toBeVisible(); + + // Check that enterprise features have asterisks (are restricted) + // These are the enterprise features that should be marked with * + await expect(page.getByText(/Email domains\s¹/)).toBeVisible(); + await expect(page.getByText(/Embed authoring\s¹/)).toBeVisible(); + await expect(page.getByText(/White label for embed authoring\s¹/)).toBeVisible(); + await expect(page.getByText(/21 CFR\s¹/)).toBeVisible(); + await expect(page.getByText(/Authentication portal\s¹/)).toBeVisible(); + + // Check that the alert is visible + await expect( + page.getByText('Your current license does not include these features.'), + ).toBeVisible(); + await expect(page.getByRole('link', { name: 'Learn more' })).toBeVisible(); + + // Check that enterprise feature checkboxes are disabled + const emailDomainsCheckbox = page.locator('#flag-emailDomains'); + await expect(emailDomainsCheckbox).toBeDisabled(); + + const cfr21Checkbox = page.locator('#flag-cfr21'); + await expect(cfr21Checkbox).toBeDisabled(); + + const authPortalCheckbox = page.locator('#flag-authenticationPortal'); + await expect(authPortalCheckbox).toBeDisabled(); + }); + + test('[ADMIN CLAIMS]: no restrictions when license has all enterprise features', async ({ + page, + }) => { + // Create a license with ALL enterprise features enabled + await writeLicenseFile( + createMockLicenseWithFlags({ + emailDomains: true, + embedAuthoring: true, + embedAuthoringWhiteLabel: true, + cfr21: true, + authenticationPortal: true, + billing: true, + }), + ); + + const { user: adminUser } = await seedUser({ + isAdmin: true, + }); + + await apiSignin({ + page, + email: adminUser.email, + redirectPath: '/admin/claims', + }); + + // Click Create claim button to open the dialog + await page.getByRole('button', { name: 'Create claim' }).click(); + + // Wait for dialog to open + await expect(page.getByRole('dialog')).toBeVisible(); + + // Check that enterprise features do NOT have asterisks + // They should show without the * since the license covers them + await expect(page.getByText(/Email domains\s¹/)).not.toBeVisible(); + await expect(page.getByText(/Embed authoring\s¹/)).not.toBeVisible(); + await expect(page.getByText(/21 CFR\s¹/)).not.toBeVisible(); + await expect(page.getByText(/Authentication portal\s¹/)).not.toBeVisible(); + + // The plain labels should be visible (without asterisks) + await expect(page.locator('label[for="flag-emailDomains"]')).toContainText('Email domains'); + await expect(page.locator('label[for="flag-cfr21"]')).toContainText('21 CFR'); + + // The alert should NOT be visible + await expect( + page.getByText('Your current license does not include these features.'), + ).not.toBeVisible(); + + // Check that enterprise feature checkboxes are enabled + const emailDomainsCheckbox = page.locator('#flag-emailDomains'); + await expect(emailDomainsCheckbox).toBeEnabled(); + + const cfr21Checkbox = page.locator('#flag-cfr21'); + await expect(cfr21Checkbox).toBeEnabled(); + + const authPortalCheckbox = page.locator('#flag-authenticationPortal'); + await expect(authPortalCheckbox).toBeEnabled(); + }); + + test('[ADMIN CLAIMS]: only unlicensed features show asterisk with partial license', async ({ + page, + }) => { + // Create a license with SOME enterprise features (emailDomains and cfr21) + await writeLicenseFile( + createMockLicenseWithFlags({ + emailDomains: true, + cfr21: true, + // embedAuthoring, embedAuthoringWhiteLabel, authenticationPortal are NOT included + }), + ); + + const { user: adminUser } = await seedUser({ + isAdmin: true, + }); + + await apiSignin({ + page, + email: adminUser.email, + redirectPath: '/admin/claims', + }); + + // Click Create claim button to open the dialog + await page.getByRole('button', { name: 'Create claim' }).click(); + + // Wait for dialog to open + await expect(page.getByRole('dialog')).toBeVisible(); + + // Features NOT in license should have asterisks + await expect(page.getByText(/Embed authoring\s¹/)).toBeVisible(); + await expect(page.getByText(/White label for embed authoring\s¹/)).toBeVisible(); + await expect(page.getByText(/Authentication portal\s¹/)).toBeVisible(); + + // Features IN license should NOT have asterisks + await expect(page.getByText(/Email domains\s¹/)).not.toBeVisible(); + await expect(page.getByText(/21 CFR\s¹/)).not.toBeVisible(); + + // The plain labels for licensed features should be visible + await expect(page.locator('label[for="flag-emailDomains"]')).toContainText('Email domains'); + await expect(page.locator('label[for="flag-cfr21"]')).toContainText('21 CFR'); + + // Alert should be visible since some features are restricted + await expect( + page.getByText('Your current license does not include these features.'), + ).toBeVisible(); + + // Licensed features should be enabled + const emailDomainsCheckbox = page.locator('#flag-emailDomains'); + await expect(emailDomainsCheckbox).toBeEnabled(); + + const cfr21Checkbox = page.locator('#flag-cfr21'); + await expect(cfr21Checkbox).toBeEnabled(); + + // Unlicensed features should be disabled + const embedAuthoringCheckbox = page.locator('#flag-embedAuthoring'); + await expect(embedAuthoringCheckbox).toBeDisabled(); + + const authPortalCheckbox = page.locator('#flag-authenticationPortal'); + await expect(authPortalCheckbox).toBeDisabled(); + }); + + test('[ADMIN CLAIMS]: non-enterprise features are always enabled', async ({ page }) => { + // Ensure no license file exists + await writeLicenseFile(null); + + const { user: adminUser } = await seedUser({ + isAdmin: true, + }); + + await apiSignin({ + page, + email: adminUser.email, + redirectPath: '/admin/claims', + }); + + // Click Create claim button to open the dialog + await page.getByRole('button', { name: 'Create claim' }).click(); + + // Wait for dialog to open + await expect(page.getByRole('dialog')).toBeVisible(); + + // Non-enterprise features should NOT have asterisks + await expect(page.getByText(/Unlimited documents\s¹/)).not.toBeVisible(); + await expect(page.getByText(/Branding\s¹/)).not.toBeVisible(); + await expect(page.getByText(/Embed signing\s¹/)).not.toBeVisible(); + + // Non-enterprise features should always be enabled + const unlimitedDocsCheckbox = page.locator('#flag-unlimitedDocuments'); + await expect(unlimitedDocsCheckbox).toBeEnabled(); + + const brandingCheckbox = page.locator('#flag-allowCustomBranding'); + await expect(brandingCheckbox).toBeEnabled(); + + const embedSigningCheckbox = page.locator('#flag-embedSigning'); + await expect(embedSigningCheckbox).toBeEnabled(); + }); +}); diff --git a/packages/app-tests/e2e/license/license-status-banner.spec.ts b/packages/app-tests/e2e/license/license-status-banner.spec.ts new file mode 100644 index 000000000..7c40a6b90 --- /dev/null +++ b/packages/app-tests/e2e/license/license-status-banner.spec.ts @@ -0,0 +1,392 @@ +import { expect, test } from '@playwright/test'; +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import type { TCachedLicense } from '@documenso/lib/types/license'; +import { seedUser } from '@documenso/prisma/seed/users'; + +import { apiSignin } from '../fixtures/authentication'; + +const LICENSE_FILE_NAME = '.documenso-license.json'; +const LICENSE_BACKUP_FILE_NAME = '.documenso-license-backup.json'; + +/** + * Get the path to the license file. + * + * The server reads from process.cwd() which is apps/remix when the dev server runs. + * Tests run from packages/app-tests, so we need to go up to the root then into apps/remix. + */ +const getLicenseFilePath = () => { + // From packages/app-tests/e2e/license -> ../../../../apps/remix/.documenso-license.json + return path.join(__dirname, '../../../../apps/remix', LICENSE_FILE_NAME); +}; + +/** + * Get the path to the backup license file. + */ +const getBackupLicenseFilePath = () => { + return path.join(__dirname, '../../../../apps/remix', LICENSE_BACKUP_FILE_NAME); +}; + +/** + * Backup the existing license file if it exists. + */ +const backupLicenseFile = async () => { + const licensePath = getLicenseFilePath(); + const backupPath = getBackupLicenseFilePath(); + + try { + await fs.access(licensePath); + await fs.rename(licensePath, backupPath); + } catch (e) { + // File doesn't exist, nothing to backup + console.log(e); + } +}; + +/** + * Restore the backup license file if it exists. + */ +const restoreLicenseFile = async () => { + const licensePath = getLicenseFilePath(); + const backupPath = getBackupLicenseFilePath(); + + try { + await fs.access(backupPath); + await fs.rename(backupPath, licensePath); + } catch (e) { + // Backup doesn't exist, nothing to restore + console.log(e); + } +}; + +/** + * Write a license file with the given data. + * Pass null to delete the license file. + */ +const writeLicenseFile = async (data: TCachedLicense | null) => { + const licensePath = getLicenseFilePath(); + + if (data === null) { + await fs.unlink(licensePath).catch(() => { + // File doesn't exist, ignore + }); + } else { + await fs.writeFile(licensePath, JSON.stringify(data, null, 2), 'utf-8'); + } +}; + +/** + * Create a mock license object with the given status and unauthorized flag. + */ +const createMockLicense = ( + status: 'ACTIVE' | 'EXPIRED' | 'PAST_DUE', + unauthorizedFlagUsage: boolean, +): TCachedLicense => { + return { + lastChecked: new Date().toISOString(), + license: { + status, + createdAt: new Date(), + name: 'Test License', + periodEnd: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // 30 days from now + cancelAtPeriodEnd: false, + licenseKey: 'test-license-key', + flags: {}, + }, + requestedLicenseKey: 'test-license-key', + derivedStatus: unauthorizedFlagUsage ? 'UNAUTHORIZED' : status, + unauthorizedFlagUsage, + }; +}; + +/** + * Create a mock license object with no license data (only unauthorized flag). + */ +const createMockUnauthorizedWithoutLicense = (): TCachedLicense => { + return { + lastChecked: new Date().toISOString(), + license: null, + unauthorizedFlagUsage: true, + derivedStatus: 'UNAUTHORIZED', + }; +}; + +// Run tests serially to avoid race conditions with the license file +test.describe.configure({ mode: 'serial' }); + +// SKIPPING TEST UNTIL WE ADD A WAY TO OVERRIDE THE LICENSE FILE. +test.describe.skip('License Status Banner', () => { + test.beforeAll(async () => { + // Backup any existing license file before running tests + await backupLicenseFile(); + }); + + test.afterAll(async () => { + // Restore the backup license file after all tests complete + await restoreLicenseFile(); + }); + + test.beforeEach(async () => { + // Clean up license file before each test to ensure clean state + await writeLicenseFile(null); + }); + + test.afterEach(async () => { + // Clean up license file after each test + await writeLicenseFile(null); + }); + + test('[ADMIN]: no banner when license file is missing', async ({ page }) => { + // Ensure no license file exists BEFORE any page loads + await writeLicenseFile(null); + + const { user: adminUser } = await seedUser({ + isAdmin: true, + }); + + // Navigate to admin page - license is read during page load + await apiSignin({ + page, + email: adminUser.email, + redirectPath: '/admin', + }); + + // Verify we're on the admin page + await expect(page.getByRole('heading', { name: 'Admin Panel' })).toBeVisible(); + + // Global banner should not be visible (no license file) + await expect( + page.getByText('This is an expired license instance of Documenso'), + ).not.toBeVisible(); + + // Admin banner messages should not be visible (no license file means no banner) + await expect(page.getByText('License payment overdue')).not.toBeVisible(); + await expect(page.getByText('License expired')).not.toBeVisible(); + await expect(page.getByText('Invalid License Type')).not.toBeVisible(); + await expect(page.getByText('Missing License')).not.toBeVisible(); + }); + + test('[ADMIN]: no banner when license is ACTIVE', async ({ page }) => { + // Create an ACTIVE license BEFORE any page loads + await writeLicenseFile(createMockLicense('ACTIVE', false)); + + const { user: adminUser } = await seedUser({ + isAdmin: true, + }); + + // Navigate to admin page - license is read during page load + await apiSignin({ + page, + email: adminUser.email, + redirectPath: '/admin', + }); + + // Verify we're on the admin page + await expect(page.getByRole('heading', { name: 'Admin Panel' })).toBeVisible(); + + // Global banner should not be visible (license is ACTIVE) + await expect( + page.getByText('This is an expired license instance of Documenso'), + ).not.toBeVisible(); + + // Admin banner messages should not be visible (license is ACTIVE) + await expect(page.getByText('License payment overdue')).not.toBeVisible(); + await expect(page.getByText('License expired')).not.toBeVisible(); + await expect(page.getByText('Invalid License Type')).not.toBeVisible(); + }); + + test('[ADMIN]: admin banner shows PAST_DUE warning', async ({ page }) => { + // Create a PAST_DUE license BEFORE any page loads + await writeLicenseFile(createMockLicense('PAST_DUE', false)); + + const { user: adminUser } = await seedUser({ + isAdmin: true, + }); + + // Navigate to admin page - license is read during page load + await apiSignin({ + page, + email: adminUser.email, + redirectPath: '/admin', + }); + + // Verify we're on the admin page + await expect(page.getByRole('heading', { name: 'Admin Panel' })).toBeVisible(); + + // Global banner should NOT be visible (only shows for EXPIRED + unauthorized) + await expect( + page.getByText('This is an expired license instance of Documenso'), + ).not.toBeVisible(); + + // Admin banner should show PAST_DUE message + await expect(page.getByText('License payment overdue')).toBeVisible(); + await expect( + page.getByText('Please update your payment to avoid service disruptions.'), + ).toBeVisible(); + + // Should have the "See Documentation" link + await expect(page.getByRole('link', { name: 'See Documentation' })).toBeVisible(); + }); + + test('[ADMIN]: admin banner shows EXPIRED error', async ({ page }) => { + // Create an EXPIRED license WITHOUT unauthorized usage BEFORE any page loads + await writeLicenseFile(createMockLicense('EXPIRED', false)); + + const { user: adminUser } = await seedUser({ + isAdmin: true, + }); + + // Navigate to admin page - license is read during page load + await apiSignin({ + page, + email: adminUser.email, + redirectPath: '/admin', + }); + + // Verify we're on the admin page + await expect(page.getByRole('heading', { name: 'Admin Panel' })).toBeVisible(); + + // Global banner should NOT be visible (requires BOTH expired AND unauthorized) + await expect( + page.getByText('This is an expired license instance of Documenso'), + ).not.toBeVisible(); + + // Admin banner should show EXPIRED message + await expect(page.getByText('License expired')).toBeVisible(); + await expect( + page.getByText('Please renew your license to continue using enterprise features.'), + ).toBeVisible(); + + // Should have the "See Documentation" link + await expect(page.getByRole('link', { name: 'See Documentation' })).toBeVisible(); + }); + + test.skip('[ADMIN]: global banner shows when EXPIRED with unauthorized usage', async ({ + page, + }) => { + // Create an EXPIRED license WITH unauthorized usage BEFORE any page loads + await writeLicenseFile(createMockLicense('EXPIRED', true)); + + const { user: adminUser } = await seedUser({ + isAdmin: true, + }); + + // Navigate to admin page - license is read during page load + await apiSignin({ + page, + email: adminUser.email, + redirectPath: '/admin', + }); + + // Verify we're on the admin page + await expect(page.getByRole('heading', { name: 'Admin Panel' })).toBeVisible(); + + // Global banner SHOULD be visible (EXPIRED + unauthorized) + await expect(page.getByText('This is an expired license instance of Documenso')).toBeVisible(); + + // Admin banner should show UNAUTHORIZED message (takes precedence over EXPIRED) + await expect(page.getByText('Invalid License Type')).toBeVisible(); + await expect( + page.getByText( + 'Your Documenso instance is using features that are not part of your license.', + ), + ).toBeVisible(); + }); + + test('[ADMIN]: admin banner shows UNAUTHORIZED when flags are misused with license', async ({ + page, + }) => { + // Create an ACTIVE license but WITH unauthorized flag usage BEFORE any page loads + await writeLicenseFile(createMockLicense('ACTIVE', true)); + + const { user: adminUser } = await seedUser({ + isAdmin: true, + }); + + // Navigate to admin page - license is read during page load + await apiSignin({ + page, + email: adminUser.email, + redirectPath: '/admin', + }); + + // Verify we're on the admin page + await expect(page.getByRole('heading', { name: 'Admin Panel' })).toBeVisible(); + + // Global banner should NOT be visible (requires EXPIRED status) + await expect( + page.getByText('This is an expired license instance of Documenso'), + ).not.toBeVisible(); + + // Admin banner should show UNAUTHORIZED message + await expect(page.getByText('Invalid License Type')).toBeVisible(); + await expect( + page.getByText( + 'Your Documenso instance is using features that are not part of your license.', + ), + ).toBeVisible(); + + // Should have the "See Documentation" link + await expect(page.getByRole('link', { name: 'See Documentation' })).toBeVisible(); + }); + + test('[ADMIN]: admin banner shows Invalid License Type when unauthorized without license data', async ({ + page, + }) => { + // Create a license file with unauthorized flag but no license data BEFORE any page loads + // Note: Even without license data, the banner shows "Invalid License Type" because the + // license file exists (just with license: null). The "Missing License" message would only + // show if the entire license prop was null, which doesn't happen with a valid file. + await writeLicenseFile(createMockUnauthorizedWithoutLicense()); + + const { user: adminUser } = await seedUser({ + isAdmin: true, + }); + + // Navigate to admin page - license is read during page load + await apiSignin({ + page, + email: adminUser.email, + redirectPath: '/admin', + }); + + // Verify we're on the admin page + await expect(page.getByRole('heading', { name: 'Admin Panel' })).toBeVisible(); + + // Global banner should NOT be visible (no EXPIRED status, only unauthorized flag) + await expect( + page.getByText('This is an expired license instance of Documenso'), + ).not.toBeVisible(); + + // Admin banner should show Invalid License Type message (unauthorized flag is set) + await expect(page.getByText('Invalid License Type')).toBeVisible(); + await expect( + page.getByText( + 'Your Documenso instance is using features that are not part of your license.', + ), + ).toBeVisible(); + + // Should have the "See Documentation" link + await expect(page.getByRole('link', { name: 'See Documentation' })).toBeVisible(); + }); + + test.skip('[ADMIN]: global banner visible on non-admin pages when EXPIRED with unauthorized', async ({ + page, + }) => { + // Create an EXPIRED license WITH unauthorized usage BEFORE any page loads + await writeLicenseFile(createMockLicense('EXPIRED', true)); + + const { user } = await seedUser(); + + // Navigate to documents page - license is read during page load + await apiSignin({ + page, + email: user.email, + redirectPath: '/documents', + }); + + // Global banner SHOULD be visible on any authenticated page (EXPIRED + unauthorized) + await expect(page.getByText('This is an expired license instance of Documenso')).toBeVisible(); + }); +}); diff --git a/packages/app-tests/playwright.config.ts b/packages/app-tests/playwright.config.ts index c82e47f68..eba5f33f1 100644 --- a/packages/app-tests/playwright.config.ts +++ b/packages/app-tests/playwright.config.ts @@ -83,10 +83,21 @@ export default defineConfig({ testMatch: /e2e\/api\/.*\.spec\.ts/, workers: 10, // Limited by DB connections before it gets flakey. }, - // Run UI Tests + // License tests that share a single license file - must run serially + { + name: 'license', + testMatch: /e2e\/license\/.*\.spec\.ts/, + use: { + ...devices['Desktop Chrome'], + viewport: { width: 1920, height: 1200 }, + }, + workers: 1, // Must run serially since they share a license file + }, + // Run UI Tests (excluding license tests which have their own project) { name: 'ui', testMatch: /e2e\/(?!api\/).*\.spec\.ts/, + testIgnore: /e2e\/license\/.*\.spec\.ts/, use: { ...devices['Desktop Chrome'], viewport: { width: 1920, height: 1200 }, diff --git a/packages/ee/FEATURES b/packages/ee/FEATURES index c557e79ef..50896cb3e 100644 --- a/packages/ee/FEATURES +++ b/packages/ee/FEATURES @@ -2,6 +2,7 @@ This file lists all features currently licensed under the Documenso Enterprise E Copyright (c) 2023 Documenso, Inc - The Stripe Billing Module +- Organisation Authentication Portal - Document Action Reauthentication (Passkeys and 2FA) - 21 CFR - Email domains diff --git a/packages/lib/server-only/license/license-client.ts b/packages/lib/server-only/license/license-client.ts new file mode 100644 index 000000000..0c03047dc --- /dev/null +++ b/packages/lib/server-only/license/license-client.ts @@ -0,0 +1,229 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import { prisma } from '@documenso/prisma'; + +import { IS_BILLING_ENABLED } from '../../constants/app'; +import type { TLicenseClaim } from '../../types/license'; +import { + LICENSE_FILE_NAME, + type TCachedLicense, + type TLicenseResponse, + ZCachedLicenseSchema, + ZLicenseResponseSchema, +} from '../../types/license'; +import { SUBSCRIPTION_CLAIM_FEATURE_FLAGS } from '../../types/subscription'; +import { env } from '../../utils/env'; + +const LICENSE_KEY = env('NEXT_PRIVATE_DOCUMENSO_LICENSE_KEY'); +const LICENSE_SERVER_URL = + env('INTERNAL_OVERRIDE_LICENSE_SERVER_URL') || 'https://license.documenso.com'; + +export class LicenseClient { + private static instance: LicenseClient | null = null; + + /** + * We cache the license in memory incase there is permission issues with + * retrieving the license from the local file system. + */ + private cachedLicense: TCachedLicense | null = null; + + private constructor() {} + + /** + * Start the license client. + * + * This will ping the license server with the configured license key and store + * the response locally in a JSON file. + */ + public static async start(): Promise { + if (LicenseClient.instance) { + return; + } + + const instance = new LicenseClient(); + + LicenseClient.instance = instance; + + try { + await instance.initialize(); + } catch (err) { + // Do nothing. + console.error('[License] Failed to verify license:', err); + } + } + + /** + * Get the current license client instance. + */ + public static getInstance(): LicenseClient | null { + return LicenseClient.instance; + } + + public async getCachedLicense(): Promise { + if (this.cachedLicense) { + return this.cachedLicense; + } + + const localLicenseFile = await this.loadFromFile(); + + return localLicenseFile; + } + + /** + * Force resync the license from the license server. + * + * This will re-ping the license server and update the cached license file. + */ + public async resync(): Promise { + await this.initialize(); + } + + private async initialize(): Promise { + console.log('[License] Checking license with server...'); + + const cachedLicense = await this.loadFromFile(); + + if (cachedLicense) { + this.cachedLicense = cachedLicense; + } + + const response = await this.pingLicenseServer(); + + // If server is not responding, or erroring, use the cached license. + if (!response) { + console.warn('[License] License server not responding, using cached license.'); + return; + } + + const allowedFlags = response?.data?.flags || {}; + + // Check for unauthorized flag usage + const unauthorizedFlagUsage = await this.checkUnauthorizedFlagUsage(allowedFlags); + + if (unauthorizedFlagUsage) { + console.warn('[License] Found unauthorized flag usage.'); + } + + let status: TCachedLicense['derivedStatus'] = 'NOT_FOUND'; + + if (response?.data?.status) { + status = response.data.status; + } + + if (unauthorizedFlagUsage) { + status = 'UNAUTHORIZED'; + } + + const data: TCachedLicense = { + lastChecked: new Date().toISOString(), + license: response?.data || null, + requestedLicenseKey: LICENSE_KEY, + unauthorizedFlagUsage, + derivedStatus: status, + }; + + this.cachedLicense = data; + await this.saveToFile(data); + + console.log('[License] License check completed successfully.'); + } + + /** + * Ping the license server to get the license response. + * + * If license not found returns null. + */ + private async pingLicenseServer(): Promise { + if (!LICENSE_KEY) { + return null; + } + + const endpoint = new URL('api/license', LICENSE_SERVER_URL).toString(); + + const response = await fetch(endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ license: LICENSE_KEY }), + }); + + if (!response.ok) { + throw new Error(`License server returned ${response.status}: ${response.statusText}`); + } + + const data = await response.json(); + + return ZLicenseResponseSchema.parse(data); + } + + private async saveToFile(data: TCachedLicense): Promise { + const licenseFilePath = path.join(process.cwd(), LICENSE_FILE_NAME); + + try { + await fs.writeFile(licenseFilePath, JSON.stringify(data, null, 2), 'utf-8'); + } catch (error) { + console.error('[License] Failed to save license file:', error); + } + } + + private async loadFromFile(): Promise { + const licenseFilePath = path.join(process.cwd(), LICENSE_FILE_NAME); + + try { + const fileContents = await fs.readFile(licenseFilePath, 'utf-8'); + + return ZCachedLicenseSchema.parse(JSON.parse(fileContents)); + } catch { + return null; + } + } + + /** + * Check if any organisation claims are using flags that are not permitted by the current license. + */ + private async checkUnauthorizedFlagUsage(licenseFlags: Partial): Promise { + // Get flags that are NOT permitted by the license by subtracting the allowed flags from the license flags. + const disallowedFlags = Object.values(SUBSCRIPTION_CLAIM_FEATURE_FLAGS).filter( + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + (flag) => flag.isEnterprise && !licenseFlags[flag.key as keyof TLicenseClaim], + ); + + let unauthorizedFlagUsage = false; + + if (IS_BILLING_ENABLED() && !licenseFlags.billing) { + unauthorizedFlagUsage = true; + } + + try { + const organisationWithUnauthorizedFlags = await prisma.organisationClaim.findFirst({ + where: { + OR: disallowedFlags.map((flag) => ({ + flags: { + path: [flag.key], + equals: true, + }, + })), + }, + select: { + id: true, + organisation: { + select: { + id: true, + }, + }, + flags: true, + }, + }); + + if (organisationWithUnauthorizedFlags) { + unauthorizedFlagUsage = true; + } + } catch (error) { + console.error('[License] Failed to check unauthorized flag usage:', error); + } + + return unauthorizedFlagUsage; + } +} diff --git a/packages/lib/types/license.ts b/packages/lib/types/license.ts new file mode 100644 index 000000000..b0194fff3 --- /dev/null +++ b/packages/lib/types/license.ts @@ -0,0 +1,83 @@ +import { z } from 'zod'; + +/** + * Note: Keep this in sync with the Documenso License Server schemas. + */ +export const ZLicenseClaimSchema = z.object({ + emailDomains: z.boolean().optional(), + embedAuthoring: z.boolean().optional(), + embedAuthoringWhiteLabel: z.boolean().optional(), + cfr21: z.boolean().optional(), + authenticationPortal: z.boolean().optional(), + billing: z.boolean().optional(), +}); + +/** + * Note: Keep this in sync with the Documenso License Server schemas. + */ +export const ZLicenseRequestSchema = z.object({ + license: z.string().min(1, 'License key is required'), +}); + +/** + * Note: Keep this in sync with the Documenso License Server schemas. + */ +export const ZLicenseResponseSchema = z.object({ + success: z.boolean(), + // Note that this is nullable, null means license was not found. + data: z + .object({ + status: z.enum(['ACTIVE', 'EXPIRED', 'PAST_DUE']), + createdAt: z.coerce.date(), + name: z.string(), + periodEnd: z.coerce.date(), + cancelAtPeriodEnd: z.boolean(), + licenseKey: z.string(), + flags: ZLicenseClaimSchema, + }) + .nullable(), +}); + +export type TLicenseClaim = z.infer; +export type TLicenseRequest = z.infer; +export type TLicenseResponse = z.infer; + +/** + * Schema for the cached license data stored in the file. + */ +export const ZCachedLicenseSchema = z.object({ + /** + * The last time the license was synced. + */ + lastChecked: z.string(), + + /** + * The raw license response from the license server. + */ + license: ZLicenseResponseSchema.shape.data, + + /** + * The license key that is currently stored on the system environment variable. + */ + requestedLicenseKey: z.string().optional(), + + /** + * Whether the current license has unauthorized flag usage. + */ + unauthorizedFlagUsage: z.boolean(), + + /** + * The derived status of the license. This is calculated based on the license response and the unauthorized flag usage. + */ + derivedStatus: z.enum([ + 'UNAUTHORIZED', // Unauthorized flag usage detected, overrides everything except PAST_DUE since that's a grace period. + 'ACTIVE', // License is active and everything is good. + 'EXPIRED', // License is expired and there is no unauthorized flag usage. + 'PAST_DUE', // License is past due. + 'NOT_FOUND', // Requested license key is not found. + ]), +}); + +export type TCachedLicense = z.infer; + +export const LICENSE_FILE_NAME = '.documenso-license.json'; diff --git a/packages/lib/types/subscription.ts b/packages/lib/types/subscription.ts index eba408cd8..580994428 100644 --- a/packages/lib/types/subscription.ts +++ b/packages/lib/types/subscription.ts @@ -42,6 +42,7 @@ export const SUBSCRIPTION_CLAIM_FEATURE_FLAGS: Record< { label: string; key: keyof TClaimFlags; + isEnterprise?: boolean; } > = { unlimitedDocuments: { @@ -59,10 +60,12 @@ export const SUBSCRIPTION_CLAIM_FEATURE_FLAGS: Record< emailDomains: { key: 'emailDomains', label: 'Email domains', + isEnterprise: true, }, embedAuthoring: { key: 'embedAuthoring', label: 'Embed authoring', + isEnterprise: true, }, embedSigning: { key: 'embedSigning', @@ -71,6 +74,7 @@ export const SUBSCRIPTION_CLAIM_FEATURE_FLAGS: Record< embedAuthoringWhiteLabel: { key: 'embedAuthoringWhiteLabel', label: 'White label for embed authoring', + isEnterprise: true, }, embedSigningWhiteLabel: { key: 'embedSigningWhiteLabel', @@ -79,10 +83,12 @@ export const SUBSCRIPTION_CLAIM_FEATURE_FLAGS: Record< cfr21: { key: 'cfr21', label: '21 CFR', + isEnterprise: true, }, authenticationPortal: { key: 'authenticationPortal', label: 'Authentication portal', + isEnterprise: true, }, allowLegacyEnvelopes: { key: 'allowLegacyEnvelopes', diff --git a/packages/trpc/server/admin-router/resync-license.ts b/packages/trpc/server/admin-router/resync-license.ts new file mode 100644 index 000000000..64048ce2c --- /dev/null +++ b/packages/trpc/server/admin-router/resync-license.ts @@ -0,0 +1,17 @@ +import { LicenseClient } from '@documenso/lib/server-only/license/license-client'; + +import { adminProcedure } from '../trpc'; +import { ZResyncLicenseRequestSchema, ZResyncLicenseResponseSchema } from './resync-license.types'; + +export const resyncLicenseRoute = adminProcedure + .input(ZResyncLicenseRequestSchema) + .output(ZResyncLicenseResponseSchema) + .mutation(async () => { + const client = LicenseClient.getInstance(); + + if (!client) { + return; + } + + await client.resync(); + }); diff --git a/packages/trpc/server/admin-router/resync-license.types.ts b/packages/trpc/server/admin-router/resync-license.types.ts new file mode 100644 index 000000000..652d4b588 --- /dev/null +++ b/packages/trpc/server/admin-router/resync-license.types.ts @@ -0,0 +1,8 @@ +import { z } from 'zod'; + +export const ZResyncLicenseRequestSchema = z.void(); + +export const ZResyncLicenseResponseSchema = z.void(); + +export type TResyncLicenseRequest = z.infer; +export type TResyncLicenseResponse = z.infer; diff --git a/packages/trpc/server/admin-router/router.ts b/packages/trpc/server/admin-router/router.ts index 7e1a7d5d7..c169e4813 100644 --- a/packages/trpc/server/admin-router/router.ts +++ b/packages/trpc/server/admin-router/router.ts @@ -17,6 +17,7 @@ import { getUserRoute } from './get-user'; import { promoteMemberToOwnerRoute } from './promote-member-to-owner'; import { resealDocumentRoute } from './reseal-document'; import { resetTwoFactorRoute } from './reset-two-factor-authentication'; +import { resyncLicenseRoute } from './resync-license'; import { updateAdminOrganisationRoute } from './update-admin-organisation'; import { updateOrganisationMemberRoleRoute } from './update-organisation-member-role'; import { updateRecipientRoute } from './update-recipient'; @@ -44,6 +45,9 @@ export const adminRouter = router({ stripe: { createCustomer: createStripeCustomerRoute, }, + license: { + resync: resyncLicenseRoute, + }, user: { get: getUserRoute, update: updateUserRoute, diff --git a/packages/tsconfig/process-env.d.ts b/packages/tsconfig/process-env.d.ts index 5a79b386f..4398d5fd2 100644 --- a/packages/tsconfig/process-env.d.ts +++ b/packages/tsconfig/process-env.d.ts @@ -2,6 +2,7 @@ declare namespace NodeJS { export interface ProcessEnv { PORT?: string; NEXT_PUBLIC_WEBAPP_URL?: string; + NEXT_PRIVATE_DOCUMENSO_LICENSE_KEY?: string; NEXT_PRIVATE_GOOGLE_CLIENT_ID?: string; NEXT_PRIVATE_GOOGLE_CLIENT_SECRET?: string; diff --git a/turbo.json b/turbo.json index 7f54d9862..8bf840e53 100644 --- a/turbo.json +++ b/turbo.json @@ -50,6 +50,7 @@ "NEXT_PUBLIC_DISABLE_SIGNUP", "NEXT_PRIVATE_PLAIN_API_KEY", "NEXT_PUBLIC_DOCUMENT_SIZE_UPLOAD_LIMIT", + "NEXT_PRIVATE_DOCUMENSO_LICENSE_KEY", "NEXT_PRIVATE_DATABASE_URL", "NEXT_PRIVATE_DIRECT_DATABASE_URL", "NEXT_PRIVATE_LOGGER_FILE_PATH",