From decfd64a30718bd3be2fd575184bb7d1782702a9 Mon Sep 17 00:00:00 2001 From: kiheon0709 Date: Fri, 15 May 2026 17:59:10 +0900 Subject: [PATCH] Add enterprise audit signal router --- enterprise-audit-signal-router/README.md | 51 ++++ enterprise-audit-signal-router/docs/demo.mp4 | Bin 0 -> 39948 bytes enterprise-audit-signal-router/docs/demo.svg | 33 ++ enterprise-audit-signal-router/package.json | 12 + .../sample/enterprise-fixture.json | 63 ++++ enterprise-audit-signal-router/src/cli.js | 19 ++ enterprise-audit-signal-router/src/index.js | 287 ++++++++++++++++++ .../enterprise-audit-signal-router.test.js | 96 ++++++ 8 files changed, 561 insertions(+) create mode 100644 enterprise-audit-signal-router/README.md create mode 100644 enterprise-audit-signal-router/docs/demo.mp4 create mode 100644 enterprise-audit-signal-router/docs/demo.svg create mode 100644 enterprise-audit-signal-router/package.json create mode 100644 enterprise-audit-signal-router/sample/enterprise-fixture.json create mode 100644 enterprise-audit-signal-router/src/cli.js create mode 100644 enterprise-audit-signal-router/src/index.js create mode 100644 enterprise-audit-signal-router/test/enterprise-audit-signal-router.test.js diff --git a/enterprise-audit-signal-router/README.md b/enterprise-audit-signal-router/README.md new file mode 100644 index 0000000..5a688bf --- /dev/null +++ b/enterprise-audit-signal-router/README.md @@ -0,0 +1,51 @@ +# Enterprise Audit Signal Router + +A dependency-free Enterprise Tooling milestone for SCIBASE issue #19. It gives institutional admins a deterministic way to turn project activity into dashboard metrics, signed integration events, compliance alerts, and export packets for repositories, journals, and funders. + +## What is included + +- `src/index.js` — core library for workspace normalization, dashboard aggregation, compliance checks, REST/API catalog generation, signed webhook routing, export packet creation, and an end-to-end enterprise brief. +- `src/cli.js` — CLI demo that reads the sample fixture and prints an audit summary. +- `sample/enterprise-fixture.json` — representative university/corporate R&D workspace with public/private projects, contributors, API clients, mandates, DOI/ORCID/citation metadata, and events. +- `test/enterprise-audit-signal-router.test.js` — Node test coverage for every issue capability. +- `docs/demo.svg` and `docs/demo.mp4` — short visual demo artifacts. + +## Requirement mapping + +| Issue #19 capability | Implementation evidence | +| --- | --- | +| Admin dashboards for organization-wide project oversight | `buildAdminDashboard()` reports total/public/private projects, projects by department/lab, custom tags, and dashboard hash. | +| Contributor analytics, activity heatmaps, top researchers, and cross-lab collaborations | `contributorAnalytics` ranks researchers, computes activity heat, and records cross-lab collaboration counts. | +| Usage stats for logins, submissions, storage, and compute | `usageStats` aggregates logins, submissions, storage GB, and compute hours across the workspace. | +| Productivity metrics for lab output, AI reviews, and peer reviews | `productivityMetrics` tracks projects per lab, AI reviews generated, and peer reviews completed. | +| Compliance tracking for funder mandates, open access, and reproducibility scores | `evaluateCompliance()` flags missing funder/open-access/reproducibility evidence and emits compliance labels. | +| Secure RESTful API integration with repositories, LMS, ELNs, HRIS, and ORCID | `buildApiCatalog()` defines scoped REST endpoints, integration readiness for DSpace/Invenio/Canvas/Moodle/ELN/HRIS/ORCID, and security controls. | +| Webhooks for project creation, publication, and review events | `routeEnterpriseEvents()` creates HMAC-signed deliveries with deterministic payloads and destination routing. | +| Export pipelines for preprints, indexed repositories, journals, and funders | `buildExportPacket()` prepares arXiv, bioRxiv, Zenodo, PubMed Central, NIH RePORTER, UKRI, or custom packets. | +| Formatting plugins with DOI, ORCID, citation, and version preservation | Export packets include manuscript formats, DOI, ORCID list, citation text, tags, funder mandates, version history, files, and a packet hash. | + +## Run verification + +```bash +cd enterprise-audit-signal-router +npm run check +``` + +Expected result: six Node tests pass, then the CLI demo prints the institution, project visibility counts, top researcher, compliance risk count, webhook delivery count, export packet count, and audit hash. + +## Demo output + +```text +Enterprise Audit Signal Router Demo +Institution: Midlands Research Office +Projects: 2 (1 public / 1 private) +Top researcher: Ada Kim +Compliance risks: 1 +Webhook deliveries: 16 +Export packets: 2 +Audit hash: +``` + +## AI-assisted disclosure + +This contribution was produced with AI assistance and manually reviewed/verified before submission. diff --git a/enterprise-audit-signal-router/docs/demo.mp4 b/enterprise-audit-signal-router/docs/demo.mp4 new file mode 100644 index 0000000000000000000000000000000000000000..26f2e5d787ee5fcfd71df0eddb5a6f4369d45c17 GIT binary patch literal 39948 zcmeFZV|3*2vM}1QZQHi(2__TUwryjQ2`08Dwrx8TPBO7==XU;kpZDy0*70s$>Zwml`0zN4aSq$Hn@eF~R=B@!#2i zGU#r$|0Mm- zX_K4$%Z6{_Wcumz37>X&S0@|dfAQcQ&MrnaK)%M=#reMq^68WNSu`O1g#XO>j{>X* zxNK=4`-z_bf*}X==VWDMVPRxpW+t|=H1c5OWdE1(pC|Um2as|Cy~IFF07M^K03355 ztymGhMDg1K0Kh&+0RyliVY4KI03a<+cE(A2-X0%6gVBv#oc^_`ATcxlWCH-Oon8Lf z@K5n`ezKnj4hZPLb$k}$6F&7o1hp{!Z(pF_rw&+@e<3_+e;y*F}rvKOeul@MH z`}41T_&gT>c|IWjQ^p6LJ>!2o{_*+r`#hJQ6hs(k^ZK{#lK}Oh_rS9W4dktW_yvf} zKnww*6c9h(d!NV#L_Q#X+Gv1C21L$(^Z$Ry6#pNOPx`;)X9DVg?`vawApHd3enM^l zsiCWhiy@F|+M51DKW!xc**t*_*U8Yq;j_Q}AE3AVSBEAqb+P|Uh;L%}Pw;240KhJ0 z2JB*NpG^!XLYq060=sV#r!CM4SgdMDh>wr*KN3fs`zzAT$@I&_D`e*vpPi1Ff!NsI z$&{FdlY`iajg^&^*ocjTlbOR1Xpm+EIxxvAN=PxV6N{*c0Xn$A4;B_SVp~%I3sVnb zXICR2$3^Vm> zJ}{Jp*u}}z#>Uba$Z>mcn;5$Q8DmFV0cPM73{AZ3?Mww&S?F0_Xv3RTiO9r03DqSKYh%c z3~f!FfxBg-@8AjKElmV~#nLx4F?9G`hLOIJrJ?iZAeK(1|18Yi)Y9C-#RzD#cQCcn zH@9~HTK}bV0ESwddIEC`uyQc}o7D&2$Uu?U+1S+1)Y#QUfSvhsnofqFd+KEBYyk{+ zGS>e;%l))F84DOYnGxFpFFMoDWdQ{NRyIavV#m+L2rx5p0$GR8#Q#u34*^adpu*Y3 z)Iorq*wO)5CE$qw77>`s&=Gh5KFbLR004r8Od~=7d>`*S<&WsgBRUTLx@zpkU#HPi zY_8F`@T=dUd^rq0FW!H@l$^QJ`J9$d7yeO#>Y;q}{BcYM$?#IyZKlkIdt|nX#YM3X|2M0=2*-Ecx=j z_|~gy;L(01;ZT&=_1B_!Tx`X$F%yLT-m>EjK?gS@28v-Yh=m-ad~ z0XDK(1+LHGecexiAjjMTVHRuW%e1c>yoU7gqVHP^{L5$!2CwJH+|6Cx?9*ui1{ zcnpG`*B9{M5J2;jrGpm^a@piae{PjoS=UI78m~6ka<%i!!t|?UGvgQFqi(~2n1yX**=PvhfSC#QguAxC9bGScy zmj0AC@2d&epRfu5VIORM+%+GZ+TzME^>Jk560EZog4-Wt!VH4*>4M!olB+f+>`H$X zk)|InHSXV@gSxY}qeQ5TA>j3`Z%vAkGl{ zt96>Yq(*Rz)u;fLN&yQ9<11_t9#qk<@Y5~-Hq$;BMRGJ%I~pQ(`4$vVV{f-uf<+M6 zd~=wW#7MdmuR%u?t(^L9sG4y zp;ZoBLjS_C&MO<@Bc)fy84F39zx(LDjX2BAB zpTa9BgZ$JJ2cP6GZ!&s^m0k6vV|67;PA^^_s=rMfUKmB8=Qk|~kZQXqV##;|hz?Q4 zRS1I-Q%76X8Y0Q#TK=&uw3;D_=G5

t?v5 zaHlqo*ALSxV^CF=@uSiO!6(_*uOluulKrzk6DgQrC6d9nIV3JV1~ot+!uS$$ww!FR z>VPsyZddsX7PO%(l#D- z0<_!#r_qA?9E!-LzL=9vCr!Kj-EeaN9^_la{si{dD~n1}<~0o=$+%mJoo|7=!sO0s z=;!0Zuu#U9@yD57h*piQJtEgGY7E=0*@dALy^NXxW@7d_+r`6(5^f~XfpcGQ=IS_> z@?_0YWqS{QGnZpG;A^#d6jl#~KHuXRD3VIB9?B!&(PjVQ^?5d5>W<}ruxI+2XcW|bB1GArTgf~ zy6!PlOAX$)d9A{en0S6DgEu5S&Y$9z^j>;(i?XZnj1~aSoyfU?Hn>Zz3yyI)h?=xv z>68KZVbfc|y7{QYMdc6$PByU~`%;6rInF3J5>_>%zdGvViiZi!Lu|vno%Ut*3H9hJOxa5{p0vh=YW^L}j>qG>x9f{tLb7zw$-i^vEkC0TB_OP`4AR@i5eD%CWkiK@T9`yuKH(TnsyxjH2qXR`c4g+_!S zehZ>GjVZUM-q*+qc^F{+rXwV~T2?JDLOJrSlz=Zbg>w8)~U;rmF08Ge37GW0eW-v6;~o;jH3)=NXg3G0ju{TkTe@)J3ES zb%hD`Uf%*TlS1j@WaiCwZ=GAdgsh3pgO_@drH8(#51mRzEEgIwX;m}<=gv@v_gUBi zU)auyr0LQly}XI)rC-JG`R3SL(K?$xU#?ZXI|_J5g|{KI@yEgF ze%BcO4?wD&`2HyY)S;H+Q@~`sSp2xT1{?>(L!jwYAPHfYfa6?=+>|L~D(Z){^@xTT zf)2Tx4x}YTj8o6(S;qx-?(v7lu!_ptL;NNe!1ZA`g zI|g_nN9cMq1(;}CrViHn>sdhk7p)Z2?_rzjrynj~ho3$8CXwa8Su#70)hC^y++gB| z4vqbx44o|yjnoHY5f$_gcN>lvh?tLyquPBTZ%F6c zrQKZOKK7EvoIi_HoyHePWn9=iLxi=r-rc-^aRESo}G?qmVL87mNfT^ULBk1B%Y-rg2{RnH8#F9gy z00sW`5NnT8&`8AFz1`d84ZvLsC3SQa$M#0e^>B>b}?cyLttb0V^g zL(1{O@K^WfD$u6G991~T3rK>6#iSQ+mt%Pb1r&YkO0v{gvDekWR>lZQyVtK)9$dfP zlFRgM2ieyZ0RX~Olf2E22IadYZvgb&ACNt60UaDE)1UP+STg3!2v2Wfq+D<&SJx@*5EGIW>BAue}>X%Sf)v5WE%IPP;T5SU7=F`silp zNWNE!A+n;)ItsXuSVu`YZ}5;ksER0j8N2CC5% zOPLs3`wNmYmv|qY2j6KNok4Vxsv_BrC_WDEx~Jf4Cdlvv^BE0P{1Wzyis5yOAR?Tj z1Zgj-BDta}vVY=+?*W4A4cwl_`ToA+)3&ht^sr_^x|Vl_E7vN|VrgBgYH{ML;RI9W zp7|-s^cjhED9Q|nd>1zw-cQd|=&hfAckt#zcJaTY(?1MEOQgD{wI}4O(@W$< zpXykt4uG#rZ)%NBY|M_L+ZIwJzrqMfS4>&1azH!g^TIkPUg4mlppIQ-@iLOpxHET!= zZiv|c$}Ve#@cSG5SLqx3MZYgDWcD@%)GOqDf%xzEr%*2mS*i?V1rGF({3BVLOSa08 z@fG}r8cL<<(sZc!#uI$CTtU=0`1(&|{R+Jvl z+cP z{dnp?T`dulpIH%+0xm5_O(%^6Qg*khw3wCwx1^1tF*U!T+G^N9LDI07t(>lhK+`4~+sg&7aYKz*n^EAwUJfVlm8I`6G%kepE$K{0>l~hpJVY5trtqG|ro;!A; zl+{g|tym-W3DrZIJ=h}~hz0;SzPPZKmF~~VFPa1|LHiyKLX)=6(_My2w9)n-?lJPT zub;!c*hzV8o>A{suk+JoN`5seJ=BlD6Nd#iWYxkei%Lf?L|7gvAR6!(wo)sYEQn8mGR!)dKSk^vX|M(e*;FN(hTb z<%LddyqLog>qhFo@%9msvrgCMDn$j51TO;?U+%PRCVc4Farg=lbfD*Mt9k;Bq3X;B zwX}N>Q|lgSxqm$*5UNGvIj@@A+f|ETvV{>^$C4bN?fIP(#Mo@fBqtRjhZ2l?*}APo z5&xxF!G?j2VioVtu>swL)0R7N0>Pfis=~pT&S+o~&PczP5P4M3bQI3~+kr3~$yGdN zTul)hkHK>`*yzPKy`f}{7Mm%#TC9M(bI;aCS0JA*PI^ZLsvu<^VgQ;D!bMg0g>Jkd z?>F0Rp3&E>on`PmZuN#hSW@t;Aa-FmVMg2eZ63e`M{|zkFbCsPvz9|nK*hVf2pG7W z``*zuS1o*xwPIXb%=f@I!rw?fcW@@zU1(0QEkKA~1q|9#iK;d6j zQndo}$V+lgFDZCc1KV@2G{q5Q$2FI;BKh=x2Y628^vN{P4y*fYFvP~(D={}SauQC( zq3l3r@bhE5O0l_Aj#cm~i|1^uH`mAlJc<0J=Ru8K?f zk++%BRzp@-!1~l8+}-s8+5;?!YgXJ;k!r%_WSK#pU|2{FH<>aG{#`p=+;3R1@nn}j1ANgOC<+0zx}kWl zfrLMM5lsY}e1|xD9;>HzRVUjb^962>EgpR@j``;_Huw8}4@x8^Xb#arWl}98U+(Dg zlyXueH-DR|PSW^Di#gSMiI`oYa~Ga!n{2>`TDtM~eX)^dLGib3Cv7}rwc_FU`8V85 zt31x|>y>_)xK?Hx9ag#a%DYG-p!$agobBRk1M^5f*qsDv%uO4_B<3Tts6Oi-jN$Ea z*(MG7baOxpWvKi(rdzuVZphD^4gTFbczb>x<-+Y6Dh4O$NrixAG7YaCV;+!{aQaTA z0nlHp>ZcpEKi*9-p z@6C@I(19Yh5%5J}%u6qjQLv=AqN^{u7oOL|*3vW}LLbyxKgYLntXoMpE7+SJYl%1c`kefb5WiIKdZlfU%aJ(GQz=6ef(~Kd#4u1pyKUddiOUo{NBYSw?9t`4L=EL{aCb>jWzej4nsee8(UD{=%Y%)Y#F@cdjCg>vbAwN)6P71G zzyim$kt5tQ#U{PXdMx0l4;S$xQ*~H{0m?Axs)cXct5J^TL4JnGown&F3Y#7+6HD)Q zlb$SDbO)00@#!@gsKWiFAHKf}PnKHI9Vi}Fp+O;lMB!?d7TPx#f7yoV3 zj-ESA^C+jq&0I_W@wwRFfLGkJ=2?btvKLrFBt@J`tFCuk+R)&=3!f)BJy~9F1eTZy z?UQNlOpP1RrTWxk+Qi`_o`5SQ_Giy*sJtfvnqk9zZqdJ#)2YviZ=#6&*g}8}`%LmQ zJEOfuH$!?XA&om_ZR%yv&>QSVT0<54M*k$aDr9!cG54hXCj6cK@Zm0(p~U*+=c&TTecF32f!vu&PXREad9<5{+McW{TaT6Gf&em^bA>(sT zQ0K}FXO~=;eKDc*>DKd@fT8cO@7%OKo*IR1@%f#y55o5_W* z8R0hE!7y(rlN#bKxWF$SgLI*NuHRtP0l>XnUKyqc-nY@i%~VfdnQI{l_@fN#7=(u- zP(kWiB=F~QF2BGe;97)bLHGW~!p;c`1Dt~N!*$J_6thGjJHDyxKOTU`4Z6B5?Mrt$Q!kdx8QX(Pr4~jw>NilUX!wb4nZ-YrDxsT#n)f`NdjCC`Il-?7K+1Uy zt5irBTGEv#3;-Ylh_Y&>cb++xx`)Kos1dRYMulWYpP9vvEFiA*&iK$E3TR(;Qc-r2LUQ__wbq?M`gt z-9FINSSbf{Tx=r%!h|p-@MdO+IXx z!Xs)0Y(c}UD}fCV!rsR>Dv!ba5J;LtP)&_`tAy*x*9w2K0JUk=P}CruQN_i;{#K3v zp!mb5t&nvGXxp_3Hy(;RGe|DfDa4Bbt4E4a*GY_0I>l%`@5Kw8nVcQP<-N#~ zQ^C9K7)5SL@S<|_gJ5+#ls=5#1SzT`?3WVLi<1VPdqEZx1x&kDP{Gt!yE*&ZgIo_T z_`k#Y&4e;USuNVIAMI&<4bLW=8o_G=clHnUO_pZ@W9|}rV3QSGY#dYSDAb<8PD;e= zl&1puV|HDIM2-iEY|nA6Nq^n&{ppXE0_<>$hp!C%>N8vq_`rd8QY)#~fkWWE)?t~j zUM~SW4tPcB==#!{^~sV0*E2FviaxKMur|zX9<^mvJHJt`3~{E<9z`+lTk?QQFf{ga zLw|3F<9919)*mnq(hnmdzps>adNPnZ4p3nXv(3Eeg?ddydYT-t;h2ZGB<5kA-+nO~ z*qL#_8!MKJYj=TswcwwnHSMyR9#?rGQIF1HX6dRJ)Bdgi+7~vhXc7en>0))Xv+A;RfUH3lsm>KJ-0d`s`0S# zGFvJFdGDu|NNswNwRVVaBumKynRv$ZCm3sAH6!DC;*ZKNtPH<kw5p4g#z`b3()x(~{Vbe@9;&0?o zQX)+F`pnW~ML}e{n^Mms3qf^qb}+0ee#8g{v6B-@`jQnNUz!~u!xE_m7v$ayqJ2E{ zQ9YtAf_-A+w{OV;WJ-#%ieb$``dJQuS&tF{>_zk>^_S^Y!AQ+?+t@a+Wj>d)VcpW< z-VfbBab-?Isrz_ZG1+HB*6E{FxfB_K%_I*8ygop=GTKyR% zM0UX#vbK@pR|WU-UNhz08}Ej0+>=pPTf(7Pg?Q5J&(o-LSP(f=eV8+<%|dyjaS_AJOWN;8`V6-Gk^ z1-TVc>b;5_9Kv#;^C|>oI)2{#x_T21E|-_%`Agm!Yq7DvQwO`wh{-9H5Dp3Y&op$U z^1(Y+MSZF{Q4SK>)h5OfVkN7eV9NQmbfoX7o@hHAOkZlBNO;Z1{W4#jv$dqWrZY`- zT?F@RT5MB2e8LquF}%zBmo*}5*KPN~PB{ijLMK|Pt zo|cve=2qLcB-eyLlL0~=riJ`e;hF@I`kJSgANu_y=+@91Ez=Ps-;R~g#n}@@I_A^z zkDqI8{EEL)X!dFZ6&+t6^gzc_7^#aVG@HawuYvq%9?)M%5(Os;=0m~+F>nPxrzO9< z3H=hOWws|*3@+uv+x?4^i#IjpCxgF*OciVNglj8wNN-b93&|JbP(?%jkecPmoUr;X zoftY?C-j~bh_uYVrRqtyZ(;oq;0RW`KCaH_sg(UAM-%REoX30W6gD609hhYZ0X7Jy z1iWP}R7nYjyd0bI)ha_>*CHu}r!fakn?BVz&1f@s6=sas=zhM)9G4sPLLT~;n-QUMoE653e;pXw}3YFQhPz_FTd z@QbhrSu0LrKV%w%M~|kmvQ$Pk!6H28+u~UUU8UEoSWH8udKfj z{^a)wjKnh;Hml@7SmFFCbs{8LM*W}@Fljm8Py|-CA8*))dI?&Tam|-1*xogGGX-?~#^;E~IJRJdCo3sv`1jt-+ z#1*43`_-Wt5%ZxGK8o$F;rXfHANHW|=LR2-(-EI)GfC-~L@tH0m@l@6W_<`mS}?XS zVT59{P5G%_$`y2ws+lkph@gwEvUvA`vlr0YtjobyEZUuEMF1;*^KHEAKOKTGWNN7; z$kqdDJGyV8eY445CdwbsPT}P#JRmQgX`U0bEuE-MU`nY4{InA;vRkO_oYCY@!Zt!S ztbNgq1P31!5P)VJK`_+)WNxt&w2=_R{c&}!S{$-OtNNm-YySJ5Ox&JQo7KSg5X`4N zoG;B;9r;-Ou5c7f*toh@=}^5w;(+&+T!1LIuBTmyH^1uZmYR(wyjY<;HmyhB6xUzE zFSQ;EHRx$$e2JR^Fur56a#mAFsXl)#M*sz#=X`HhculWZsN&5N{0}CSNN=%j*7pI* z_J`nyL(d(S(dnoJB#odefR;zie$4V|)ai~VdK9>xhojeImMUvgW-kdiSeNdK5RM@p zff=Qp5J{C`{M5RBejW*r%H5I8!b+(Q-*25NiYgR-PP(4)_|q?g@p2DDhV=lK#Lla^ zs3ULSSB!o#K1AY9gV!bS*&JkV{bIqGSun)PiQqlDH1266yp~^o+)UoFkA~J&Q4`X$ zj*y!7B+rok6d8G9Q9+%pY%*Q=rVZVS>xRj|l%04oiaAAGjJGrQr4*hfW5nKg#hShn z&Srn!P&N>{4*}i{u7-mRZNd)$%CE5&P!NakN;khvqrA4r)ds>?E(*5PA4)xfVAkkE zHKn^4G{`9Wby8Sc==LH4B}HT{s^Q0;QDwemvL5S3>IrgR%7OyhpP8LAcF*AiD3EH$ zoP;cty<**QE?>^(^TEH!Hi)Y3zYI}ZE64?>W9%rUt$+6yn1{XCj?_zB8MpXrw#>4u zt~bQnzFI`$a80H}%#8-K-zy3)e~E13w!h3%KiQtiH+K+*Lc>^)zbZh5mj1QBJF`iW zGK;KF5QsJxWFL2PUuK}#@sq9c`xl#C3|%j}RxcK>7L6iG3v}yB{s2-7b$)!fJee;+ z4s0Y=jFRRj$J#M;@<|gHNWtoeekl?e`(Ep&lhF)BxM(R7Ws(hBM$y{^Cdt*754I6g ziIxj_PjK+Y8eIk6WgEMo{tkD}p=rjr!f02Rp{L`W_-aa!2t`#x!D2pm9DGzc8gJiC zhk~YL@Wab)L=5v)$*6)OLTZI0wL}Nf(3wm#0{{=;<%tQb0pbe*k(JSDq9WC|XGw-s zOJwlf$~Lw%e!m*vF63W-bo-GH-o<9v(S#N&m36E?mg(Hwo$pFL%|0OVyHyD>d1iw& z?a|=3zwh)XmWg=TT{=c`}W(+G{U&= z=bj^Rw-=9UF~ZE|s!9_$;|6%4Vno|r84fQS=&7S*TfgRNYX&V&(S%LoRoy6VHremC zmafwT1Kz=iy3usCdbqDBN)ZM2>LW2)V|ws^Wvzy*X?3@KiE@|-6NZWGvl;pv96`fe zV{9`eyOf666EOQWCS4Fp0otCP>)l?$eu^b<+J#d{Y%X$3b!DR{lrW)t=&`g zd6`{8{gN|=MR0qWK|HKl_$=h*ZOXSnp*;r^T0_6T=U_{7UUWdQKjn715XCL+y)mS> zgALo{#%1j)2XS;)Ez?v(a+pkbVdy3DUec{|+_0y&f6+gR(F2J~f(271w^GnbglWw8 zE;i(al=j1uIjf(`;v<@W6dScv0EGd2#JYit+KJ`ULc-}8!!`45p$t!}iF=y+BD~J5 zq%dHYK=t_aSWBRp?74=@KTf=Morc=ikP1Kz-mfb%#@~+fnh}mu@TPA%|LUFMkWQSR z80}k}Rz|JZ?$d*=BeFdsN)Vb$w1gF&Tcg9fq68Z$)P%(krgX;bpkgCO%M3}ldpo&p zEdp2lo8NOJ_78vSMCct{27d?}57sP8gSzh)t47f?D|Q`K$x>~}N2^Bp>T#SuIx{g_iqn?f3b-Dc40&o!YIydR_p?FFA{SD~AF z>1%wTm3P#t?{-e0Odvqj3|?T|Q%>DX$O$0tkbDKJpKPjHHdwKj7ekufly`PtKAcBU zA>89O3pkfO&vV22$4@LGw%2TKz~pkuG;QylBvS!^Zi0o9l$Ii5eaPiAi{t+D8vI}Q zzBWz94dr>$Wh|CApc)Ri_4e_SdIU5B?1Pt+GzV{5RCUs0u#xXb+U&lLa3n2g3m};u zo-P7CgQNXeLix9O#23n9PxMyh-CzPk0kV}gB|Xg&Y9NJ$oM1h4ziqk_`L^hc9I14c zulI}==zef`l!fLwooeF-0iIpN$A4`z6M;pbz40$qu`Ad5XWBm}c1pJNkneE()<28? zsc^qdA>k#W>aI)nHE-QBq z?DRa2S|Mt#(b_R`9|TXC5%zCYvH^Ff_(2eaf9gIAXdCMiZP>fo`u0rxZHWs~WKG2q z4XR#=p3txes`U6D>XQ4Rk@7XJGrSV~VAbQU0S4n{FkBxD^Quz8x08zI?oFkhy z9>W~vGxapw^u8O*ZCBQlS3#9WSZLLdez=|OX84X+Dlz=)D_6i_q3BE!@@)+2E`(ha zm+w%Vq~hfQuVN1^cOWEI8wJ1g%n#&F<|>z00n9C!_o>p+rW+B~adHw;f;zDHU-hYw z66>#SpcgSL%0>Gzj@`7Cba>r4)$UgFjY90cUL9x{HTV4x*(dliI{ghpFS)iS{l>gc zhLP>t7_=;;{aaAF>W)fv=N0^ttF5e+>lN4`vov9fv^iLcK&Ag5a;^lZbdCnU58v zApDY}gIr>niGHE`QWhZqYE}kEh2-)tz5LFGSSEj0GbQ>7#=f&hP7T;VJno-(?9<0D zZ>EWnxG;HxdPT}XVc(w-ZehySnY+of}oKQS>4iXdWD4Q8B%9zC_cDA$7{1S9+B zS;x~0w~%cRaJ!O&a47&2}(`X|D|u!oCo;bhT(TsCK=F zUq(l_{8`SsCKta*ksq5aVcJ4Qb!?Av$}IT>C5rZEQAV_E)}PWlls`yk-Q}lPQu&Y4 zY(nDDn2YW<8|&(_{L;vurP>-g*Q!k~38|ntNbfjsQ}2}4MsjJ3_J~QgnviYTX>H_d z-UyD~>7~eB1K)y7hz)#5(m0EP(_x=l1aAgI8)bCw5vMl_?4LDAXE~Y4_$YoCG;X1aK|)n_JX-gA@tr@3 zCXfSEzBRk1H9cz2@ECRLRsI!TX!(Hbx^w`g*%Z_gDb#6hHWz(IG(>EI@D$|}6UG%v0(+P9 zu|43#7){<~o6(&SLgQE7c7p^ckf0NDx{o{H1;w=X-v?l5-LXPvMCnAXL_jA~v19HK zzZnbA1$PiNB|;1}X-qgpJl1zlWH}1}I!!cL;`Pxliee$M3=>Q?l+XJpWSrO`F6sH1 zwCKI+NxVSohcquCR80p2OB;vmM#5&~Se*~Soz@7ws0L}KQUVdtV*T%lQblWw%as1? zi$VT9J6+a%{}Dq`eQ8Y`ZL&pi<3;gR6J0=lcXl~+cdOg6cv@7y3jMd`g4&ENyFvkP zi|Qtd>L6vHRO%4CtuGjbAK;umMBE!yx)+YMS5sfAa<1G9z3B=ou?=N8ECft%(Ju4D zP#>fFv7b4yXp=LO8B2LCIh3}o<(aJP8WFN&jJE|`ZVzHqDn=g%yQ&e( zxRx_NO=XGEFsX4kx(^D`iaLMj_6MFFv*2Mobuc`Fa3vU&AeqKHm&^$6RH-`6zPZ6J??$t z2j6I)-{0sc?3Pr>zbE?07QV*lY)ag1mgeq<4vQWa$U~@3M(yyBOPEiAF@b+E-%5N5 z9=;(b8T4v4U0-wTuqc8W@-367eRp*KCWbq~Sw^h#&V>zP62=weH>zVYXIKBj;$5;Zwp^bXU|P8aV6@@9|^Qw5SwHhX1J>(!3FNZm-ud0CRlEb z%iOi(xYdwm{qXP2{xt*UoxcT}t+Rs9{*dyY4N-bi+P0wCsd8-{bj>vpu1!d?dK# zhpnhd^Tfp);*|+hFF0G)bu|k#3Z7#x9a3sgG!Z7sqB<@Vbyip%s4gN>|7a=(OcVZg zq!g2{vB>jXCEVQ)B>uv81&^vhiX>rqUrD;7s~-Az*BJKIGP;!Lay^&(&Y&FOJXLr) zcr5+?y}J?WD%$GT6`0R23UrTdj$+exTXgiGlUW$>d-P9I-g->K5$)H+?zI(#X`xwe zk8a@7mCG7!GFRP9aaT$~F$vZZRlPDC5Trh2ajggHKZFiWe$ujO~ATB6qHYE9j~5hXvJ^rUKX9FWNG@!^f%~P#0;h#DV=T-uZS(Cj`?L>I?0=0PUtynyjbDT8biE~?;y(Y@2Lg+o*LP^PJCujNMw?c%=b!0B=>SuhIaJsQ`uHY70qaSlm z5cV8ab}8s=)&0#9onWR}jD!_!s6aRj=?6j9$=oE>pImnEFKf^JSN#6gh+V`|NGk!! zIonixD0Fh7!@sxCA_^d+Wi#Kk#vWI%#mE&ic89U?cGSk=jS0^t1v#)1s0=xUOC~%qLISXK5E4 zSBLm}L@*sidn$Y*@Z`x?!jPt7Q*J1z!Upr6-nKKZ!a{%HWk-&x?-_sX{2nEuzZ zL!u>dmPvz3>bhl~v(BwBEfx}`BYrcCtV!*FR2cTxY(ClprQ9I6-#ui6UTR0BG4}R0 zd{0kK2&YU@4>*MdzZs7F5Tzpn!L4_$)dlU;+P|e^)lA;*+yB65eNWbgGb}A%za|q! zD}47N(Gcw*bhNcmXZDXrRM&_@9HlJzAU8eG_=9;r<=Ygo+u)Y*E4hnd1x|qfGFGgK zqi7IvxVn3>PiNT<;Y8D12-Z9M+d8)3gQWJ`Pl{0qi>TeF;{|N%sWP_b@KIwb%Ih#O z&th-74CiHm{wD$)BTsY&>S^;N)Y7LGa~QBdeu-!o&`fWqmLM;l&HL`2R_XA|wsDUT zzk|ozl$Z&(EWACT238}V;hm3PkJkJN(i6ac%ke~ zacp8&wG;`dk6T%v*#nAaa6)@qRU(S!8lR(B%Jnx+l81wP_4J!VR4@~WPI`eJXmrEJQnChU%VOv&|=@|s{G%SJz=K8ol&_|gNg2)XGk0ot9r^ldvrxLa*|w-F)c zf4x;@sb-BviBfbd+~+TWZld$m(| zsCxtCkr9{g`2PVXK-j;n%9|qPO?6}-Q+d5E$vAv5B0iej>5_+>1*DjqE7Q#7_S~pt z_a?1?4@;{qgYP(-N-`svH*O&+agGHKKmW9W9mr;B zwSYYX`c8#Qwn%5n)&QvqWKNYX0z$t~@y>kra8n#$f0PHEm zhD0TL{6P;I?crNyh`*0N$aBjC_GyA9AaM=x$#}(rci%(4`#|ozA?RLU@G#%Pu`Uv} z_aPWe-4x8fD;~=X%m7p$kyITylLRq%Y)z0Gi4nw5RojuYSXd)0^a=(EAN4NUYFJ2= zkkG10X|PxM^%90=C|N+;<3TrgFSKDS4gvI;w%8SpduawX2?&9yk8|$g)(VkaJqq86 zt(9=LY`E#Mhjf{=v=Q--$xwbCdv?f2Av z0%cAcwxQZ4mXSsDIGD-nS6R_0pCZGuHDL|H?25eOngSKEUE%Wmq-^43!gYTFpkBM@X6!CZwtO-1MV*Db0 zeL<&QI$5@e5^RQ%8+QJa>OxWd3N#8vcE2iWSA?BH?+B?TPBsdvxt0HrUE8UWV@#~Q5bsg?@Ayj~G?W8qXwWsn~=in-0lpV`j*tE#?jgzAJ_Kc6^7xWi7sER&lT zW6dN6QQFvDyYrtSV@pg*nS9-B4kc$1izFxh;_}AvnI_7e?BC%JEdM<6LcmwJt4-t; zDZC(xP4mx> zZfBeAckgs1iMbBO-|~0DwY$DG2PR!;n3o5Djr(0zLao1Bz-UsZBI<-l7^du8D(_*+iyNf_Sp{ZvVn`%P#P<#_$+M6r? z|Mp^Jz!H7?mW12Pj+nM&5Fjpqdf2#uC1L0agqGCN2acq0V-fC&AnKyyHedcDNuats z`n%Qpjd6N5^!_8JK!5c3UK6^KBrqBWxnf4xEI4(- zT5w6m`S~*KY1DPYL$$?Y(&T^(EWR|>>1vqv z47V$8Z#liiF%bwG0KbcLDw+itSZg<_i8G5Ltt&em9Skg znCBS+$P%&OQE?GZOT-ewP|-NQgP4+d5hC|VCd&uh`bEqs*v0ePf9etKMU`&feQ|S8 z8a4QpZ3<=aK{^xOL`02b5Gil2e>5I#9(p$@G`@lsPSQnZG?85yUYERvyvg|~!T=E! zFMkY*7@lz~GSS4qxN~~j9xE?aAbT0y5mt@Xku1U6|!D_odeH~us#68_UY$c)AU8LZ0(8LO) zn!S?2)#$N_g21#;;~B>ogQXc>4r<)gzI|EK7nd{1AF{SGJSgX>aloGqeL&d2FEpd+CXUCCu2bd0%}Abx$6#|}RkYhYWdqRw;OY@4C!8jHPtPO~{XL92t%rs|#I2sVOw zruY%FK9Z7Yp;h7ZHc>9qikRcJ8E8VSMR)6G0Y-+1Ko&;8yNWzD-Rg&%|CXF_=M2Th zwk*`@?XXpdFvMYYUIDZyJC~0r_Pp=Y)Y4QvWNNmUaYX0TmeYyxv*oTAaPGRl(g2!b`G51qL zg*Ks~k9^+?ZNVZgpCtHSaxNz4ZqiwSLYu(<<#A6Tfa?FD@@zkrn%Y{t&r|f*l(~IO z*9j+uY=xGN2nwr!AxT~JBI0;tc~S&G;O2BPM4&tN9mxB4w`3R@4C<*Y9qS)UZBN>;{*!`xdUNDVR~X+`C8U{G!HrFL z=fB183kc}}@Y*#_d|xrlpvD}>J87@5KeQ(GwmMNevt#3i<*Vq0;})7YRm$jZT+GV> zB#}5d3G9qUVzmc%CzKuN;Hbtc1T6`pq=2_tPrE_6mi!~FwN~Fb8hte}pP=w3cbo&t z+MAfjNs!y2i#e_?a3XRC-GOm6fOMtc<4#H&U}0rTtxse069o2K@~@s9*00>k)krb3 z7gx;VVFvkioR>ADL7<>9D3|mu4n`Vp!6Ik{r>H91p-WUZ_HmiVC_PW4;J%qyNcB(Y z7ur_s<4NUv6h_LJi#f9U&FQP(b2D@~S3bhI<^_(%gmP$OcvM=%^Mx9!jKir}U`PtR zp4TBi1B%2C_{39LoQLT_L2M3DHA&h>TgwKKk|* zf4X&_lv_n^@Uzd5l0bN_g+i$Aat#!OBE*`I=XiGByhvCQr6--%RHZ>K@@@d8Xc+Cl z1$1+53cY_<8wN%oWXf^9EwWkCF21o79+(Jac(Td8y&&wNKz*f&#Y%|tn?M&57Qj86 ze&vwtumU0vWJC%Ry`F%ZD%c`2RQ)b5DwKq)D140FdeJ3!;fsSHDiB}^8k+Rd)ck2!EN$XWynXFEX9kk zrz1~PQm8CMhu*qB$pyQyuGHO#8R@(n8r4b3%j)_i%jQl9BEB`5^r+^gybYMTw2dl} zF>#C@XGCdG1L{36{b5nv%qI<{u%w#UMS;4(SRfc)rAjY>Agb8XH_&g!E4dU9xGtXTbD<~P}03%5-_ z2l6-~qu~t&F>HZp7{h{_ zXSYS^6tm?eF)h;DhmG_Pv)3PhJkWxhxjdK_SUlI%pVo2q%6spJJCq&pGLO}+x*MO5 z4ungW`Q-_!>K=q!Va1UpfXJt(fmv%HOl&gLy=p_8l9w?I+UWi@Og)dYLAO&h=(GL1 z%B3pUdj4#e#S_9|uO#XkV8Q>;bV;Ry<#KVjArRt4^(E~E!Xha@Tk>*|fdGzhyhhlL zVORvrV8b%ZhTLTI$Wt*>X5_ZLl)0F-?Xps`zIev-R2RyGhW0`+dRsgMK^P#$JfaCC zU(G?qL26l%U>0ZLQg}MUfUl-eEl;@;|MP6h z89gRZafTfe9iUS1L9?7>%4LPPww(D2Rl!`C28tzz{U?}1^f;3vtS5f*q9etc0(12# z09~6O`o%RdEp8GObMW6R3Qk-}k-He4THd-emG$bef*gPjrQylyy=~ed2O43@}{B#0~|Kcbt<6aR031ZJoA&zs)T1 z|5(a}!F;_;q`hxurm{-UZ10q{q9ijOIdn{RMpHCu%BSf9n?c%ZzWPD3#e;kQ$tZ19 zWn9i8>>R*X19%U{R8pdmTQs+xw67DLSPSrfO$O80t8XLCZ=Tu~r}^LZuzwQx&=ilm z<1)1JtsnF%iwATO(ksLmyDv`3wpkiqQfew$#ix$f&iF@@lK=aD1fcoP$cZlb_xn{V z{XttfOEnVz@k6Qj@O=J2X22^Th;a8F)u*r%BJ9Ubo2Ozr9(u-|_OJMUc4u=r21->A z?dcYrR4M3Q!hs#ecX*NpPXpC~YE*f5NbMh1@2Fk;b@GEnj!&RuB@)QYwH@E>7#?}* ztnw?TghdHxd^c=A+@)%4S;m9DFGgct#BoyOV1j+lkl;6fmq84QQvmtPr!`gWGdydY z06m1aNbVHU=Z}x|zGr_8$2+w-J)D2Qe(dI{N#ge>DR6R-NZyc8M`EwSu@Y|ODgL=$ z<k8wnfwPg8mfsBi8ov+h^mw|0y zv5tgb$P!Ywl$Gsfbb7%P-MBo6-|VJwhOiexZ|FaxjsgWPmtveRaO^wH@kk14(*l*; zck7rn#24Wwy`P(x5cnIpYmLugEaYg0JDE91)BSyBxPW(zSq6kZS0XO{lI0rqY#&^Z zPE$Z1cX`&$ewR4zR1sYFj9NILnE3Iyhv8&RiQqv8Fe{PJ+ixaqQV3An`A?Bccw&%u zY8l`!tFe#G7k#%-N;J>4m4lFmT+YR{Q-}#w;!7j_2uq4epGIg2&>a30r+F?o0x<;N5TT*$vVd(-pItYU@sm?2wGMYRoHq{FgRs zvg7M~%|77JS1u;nDMv0>e|XO01- zbt&h9W@=u&JpMr2pe5Iqv<{S3_I8K`Inm>6#{NbgI~?jgYbgUBU4+_SXrsyJw52)l zK9`n&q!%dJR+dJU(^@nRJS1{-NQ25DM4D%cm;f@h%}au2z31W35n5p z{|d1E4YD-EmWMvZ(EMPAM0*xqd4A8Jn_(d4 z<$A4sm*aLlq9wC~gpRCvDgj^iT9ei~sm{=Pt26OM-~v6K^L+6c6NVkoyY-zfuXjM0 zzeB)g!UsYFyvV@W_L)4y*^Wwp$CbK8$a5yyFGI)|%)A)drQQIduMcP<6-o3mFpqh; z;zKqPWN8aNnEP!Dsjjs2>WWYx0_7}0o$qY75a@ztnS`Y}bp@-n*w>0W=IhHdb`RO) zrT@|lNsP|w?!dZI`vPx%xOho#RaCGO5$_R$_;B`LH|OE-DP{+=)oGBjjw7&H8@&+9 zQm(<~BCAXkiJF2X4|$$$E!8F*Gq7F96Zgx)c(EfFS`z@h~TrCDDOr0;z);= z(1=Mdmu7x~k%S9UVdLEgEVbkt!vjAibVlS~uhbaEPDuf3DWsH#$j3p|3GDqPJbQH} zmyH^@zj%HkxX4MHmLr{Y|3hM4s_j(F$exNL0!bOEeg?Q=9(^vtnK4vB$fh@3Rv_5U>9vC576 z5lbI9{x=RaLP;tp=-vg2WV=}O^Mq&Z+7`viE+A6K z7%u(JII5(%M@WQs2Of0NTU>(g>k9#vA;HeURaI1DDK^wo_(COM%a)%l(EggH0-{~$ zVGve8L}Tg=7<_1Jr4ia(N4aV#F|`#>DK)HhiAR}I=`Nx<6Ry(+BVl*%90An5xvVKD0YRW6yG>0&wHj17j|rJmUib&e|6SRyzUA*5|HZM5p&xXK;Pw9!&_*;9 zwh3u^iu7GJ3)#tUfU3#o2i&6eWU8*Gdrj#NN}dhIc7fOM{6N&&!BNMV%!>Rj>B;r+ z=wQ#l8T}cLP%#6zDv$LqC2PGYgpFO-pT%ONPzocopbvv8<>m0H@eQbBQO~6PC!li| z_jQIr&!rKQ#C|yjbyRFPdb8b4^Q3d#$%ZP^FR02CUI6y}JG^BBj5{ZbBK(rDxmvc6 zf{$)d%3oZKmv9rgVAz+?qUU_{vHN-w{9<(HH$+E;dRxSf0}$|$4{WSKsrzAuA~Fnb zfbixL4FGh4t2CLhlC-{CwR0y7hjl;qW3W04_*D1PJ*j@3t7KAdXL26u@zXs<{@Mxp zh6jL0jXHxfdT}o86-YbdpxlP%oR1!VmNh5NEA+7P@1BViwc~P@iH$Av5B%d9=x|Mz z7tFbntyEa%2`4umWMTstrH6h;0~j9Kz2ER1a>+4wTMyIU?fF<;rzT_ezG#_wMoZj? zAC8i5)L(9GUgd^FG)$n`#%#P+4xrZst4Xg&>sQ|19V0>n$I=Mt(|EIY8&n#cL|mU_ z7y_H6t@U&Pimx>fqe2QN1!&c(Lknkca&5`xPyApsjS9~88|hDtDOAq0EdV?*&?856 zsZ5!H_>WYMbUA#B;{LxJNyX9NzJQPPl*VkOoC89}C(LS>1hfcJ{vi-gv__iQk%=Y6 z^V~Wn7v}B92QL~Uv2;mXc0bL@Ne9uWoktPCigGhSN_=6aII7%xNODtH^x}L-6{SwS zdP1bYQ6(WsZI@6C>|&J>3mlE#NdergsYC)_rRVzAK{y)9h6j#9L+q)^qcY3H%eHp5 zs!VBFE%da6eUB?zIujF~v8jRvqNSFDRrvWr={6*c!kM9?a{GQ-8$9EON)totT~cB6 zu1CkvdeFi{w6|W`?p(-p<}7@+i(V zY^b>2KHIr&?X}u2dbpavzYSm4LyQ%I4{bX@8E=VR6pRZy(t8UcqmLZzST5=T+$|5 z_1fs}2@D5cB3&gJ#PtsyK%}=NG84PqF}HayaUPg(K+RE-FpIMqiJn33S5|^}yjQx; z&9D;tFX%J+nUT`l#=`^X{W|5fZ?Jk?&al5S$g-|RXAnZYSdpf7jnV>tW_YS#sj8!Z z&Y5*&ugsDTUa-b5?itK30iC2WgS{Bn0Hve%oR)-<5m$Bv?IX`0f6M4)Zu)RfJL8W4 z9y$IE5}YOBIYG5~aUXq|r7`4!j+a&f7km$;OEChVC_5 z28do)NkYkDt_bQi&l30C=ZFN~g)#Y#*Nw0-`qRd>o;Ss~`oenN>fFAW)4hG7q>DGh z{h^8SUaATMJ+^gONgCVjA;Fl>NC+s+_ILuVP310DFgc|ICj_{aon63uZRE1|z0lrEfx1hvY27ityf*{w_V6>i7Zbf-s z2@1XUdx$J+KmE;zIat24+5;6l z_{tPF6bq>2QVQ2y%-=FYnofbf+FWPo(EMfs&)vVfS&c6^dy<^->akDe(n*dh2)uA6 z(0{i!GpE<|hF7h$`bn9Gz)uZ%8x;J)zy59Hc)T) z9VRHzgzC-rUv3F-OdZ%UP7mupo-yF9MLC4X1I8}_*e-k5>T=mVb|C#i(V-|qqL!>M zl}2wu*WyE|Qka}m-qK}8eXht9OKf_EhO$%@s>pR)J2OhAdIU0XQHn^vkg@Zs)HE3n3ZfDh=JKq0PE6U$zk=RT?gZYem*PBBj~#l#T<{!o-))P z0>}?hgdA@~MAFJNI+I&YQ=f2@u%j!JcV|Uo6rb;T94(B3P4alokmp((Qz_AF2_FZ4 z>?^%>gb_5AT!TC;H^ZpP7&+6VsV+`}VV}WV=i~vzw#L z1&OM*P6KM##tfjB9CBCf6mw&_IsZ3C6i|>IO6OvwzEA4Ifd5ifInxGCQXFX*DD~#+ z5)Mjx2t`ka3~h5!%$;tgDq`+QRh$GkvtQbzE{slI6T8K}ovs-V2Q5;^ElrCEZDRQS zrwX_l$k)&M0L1>Za38t1H?HhGKwnR~0eKa-?6c}AgiauJzkhoWg29z?{Df&%FD6?1 z#|{*=MrVjxgCJ*dfS_k+t?W@AGk@wr;0rDIH9Jkdu4>K=$SdnJyo_ zA|!^S+oRWZ;W*`1VdJWq;2Y1{pr*o96ES=1|70Az#-bpO+e0KEnSkY7>BNcJb}crd zMB_u)RsJhbR(!{oL*gPk&(=RB(ahM=0VQFCI{hFhi?@QSm%-)!$A3XBS!U3Aq3f5j zecM8H798<25l&cyd7y+ZBL?GcU!m_R^0!)&E+TZmOmP9B2!#y7ClvH6WI${H1TeR= z8=`u1*c(|$w-6W(ZZ)XcFy7E()hJ8>Hoh>~k;-@RZxIg)=cocSIS;dGAPr0VV$gNL z18)s+<;bI$vi+Fby+d~#FMHWucdBtf9t}NJLaq#@8U(S-<6Alun&(0nqL8{lq-9xr zqL<(QT~cbGr;&=r1m8&i;?laUH{^`XEFD+QF;lLZET_fH_B)WBvjG*!<__2yH>9nx z%E;6P-Xg_q;5I>9u}_NJ!_cmzBsAtuD!qTP5_{y6yAkoRNw|B5!p2wt71x*80dm7U zGCB})!!9(a)c$<56pktzCiS|IrQeveG#C1~aQz>FB9{Z5N|=i;E_w?kXqe17TkSI+ za%|`MXRJ6+vKugzWHFV=#L$tfu_Y_t0R4U_pa0GthRlW90Y%^dvlgbK2Ke;=X?-@E zN&o-@001m3=kk8tvcnU%t+`Xj-QA40UVx8Sl4qV|zj=fzJvqz7`OkyD18WkBS9i>g z(Ysj1bcyp=Qx&H{Q}4z0QyY@r$k9@O000932qO#~C9gaTNq7ToVb%d~j{rho<4oCX zSZE@Vg>Gw)m}bA|t>t46-79Q~95Orcen$|U)BU~5j84ny6AAK_e~x`B8}F^}ehxm# zC&n<4;;xfCG|pr{KIY!*M$YzupjI?c7BZZ z4CHLK^%k6?lo}Pk&sdm3h)00r`&Yt;r}j6hrBJq_o=;7MA2g&4H(X9AGjL~UF6l#{ zMkhpEy8@2YPxw!wYDNw8&9W5#z{U;wex0<*0>B2qkMN2ov?DC>rw|^2$sH*U;8EB5 z{Ix7I>HInKv9O5eD$e$%PKAd|v8t9pjyYBI4OmA@wg^e(o0eNMJDZ@TtNyez9H zK7dgLLv`tO3VTUDw%T+6v0)$J18l98Ua;oTzX-+JXkyS~F;aF7o=5{I*N89Fe2wwd z-EDLBv8n)uo!Dqv>fpWJAT%1HU6zujY2BcmHQ7csh~tytM3hX;{9m$FT&2cW_?MB` z%75w9K)mQ%3gK{IO>7`n{#^JX;DTQ_UiiYozi6SrEN zRl@}Feti--!1g%6l%5OHtDQTb>#X_o*wWq0#x6;8m~hvX5cDOs0uN_ZMuGi?e+%q55|B^}qObPLco2oWjT6XG0 zvTaDD2v=PG43qdr?m0rOgZw7y<=&nwgSD@|j3%=ClR+6#xd|VMNyr>DrQ;X#et2TN zWP=``36w@Wzd3MG;tHUv==t~c+fHyHsOQzn}vV4L8lGL?to8tKWK~;v9V%RVUM_=>W@y_8GrAU6|G5 zvoBO>7*ry;Kx6`6?;;dbkH;uD_}BCiRg&`iMBzO{P6SxrwR6dWvrk))XoNQ!cd=c+ zq-ex0Y5Y140&3>Qle?cC<(O%co;}$i=V2{r>u#|&&2W3?AtsLIvZtuKQ{hQL>n|Wf2TOolvG=b#5 zI1;cruVqZCdyJH+1_OqN?*fl&4Qtse_R16oz*W#v!lIEt|NE->yt0;PyA9Z20qm)J zx%-PNvPOJ+rO*(3Ha6`se=1(_B;z`%(>!()UJe%KYMYnnIZKJV zmM!6*1uR&s>1yor5UkPGo5Qs$m8R$vFN2FA;p58>-`u<{BV#7ZE17VhC6YbuaCYO> zPzqz>64GAoP+b*et~s-3X8b)3u}9K#>%Aar*jRk2<;hFpK zq7#xs)`f1;KV+z3uU?5`Os8SI2gg=gpjiSJx%`cLQ0@$D#CqxyjVbdpia1rnW;W#n zdi6BP?zo4ARZpK{=uC7pKgb?=chHP;FcWr9+JlTv`k|XMnO&CbBC;h2O3VxSpk-$I zMfxz^q%p0xl{M?G#=9UL8!@4Okl*@1I(1a{M|r_lhbb+Jo}DG8g3^%2Z1`y~z1@M? z6a5U{VjU1GmMBeUmEu)=;e+7xWC93;58tu7R{9hF&&7JnqH7=?Pl`Q+ga2CtA_d`a z-5H2a!d3&a8%^k!g{3v%1r0};6p?7Yj4_W*N`xeomZ`Tj&RR0=LS1qoV$+Ov9)l`W zULF&Ks58*ew=y9Qu;rvb$blsR?s2sS3-6I9rE4qWM7_#%)0+yy0+bRp6cBbHmhPst zR@P{R#t3Mj=+x7o|7`nq+6B(67PsgB3+-E+E$!b!-#tDm=+H@@m-MgbBLVw>bea}a z5Q^GD%Qj>(M2p76D1@5g#a`Bhhh(9o!PL#2??hG2Bz~P2mAsr+S#g=yA3xw&Ee%l4 zaF69D8UCAi>U5vUf)e-`8y9`?Jj-pkcjQr8Ywz?Gd6=!DlC|^O{CTi#H*&Vm9w8@@ zbzU-J4p0<&puyU$KIms#dD4;}d4zWt7)nP@2`gmWnOz@f4!ZhSGaYWQ#)}<|d+((A z*x~rkyhYR52LijhIqgm@-UDKK=ls1Q1d-bppy;oWT!N$89kp!=lY#As9$kg0by4d*J;{U$!u=r+i zI(P23B2s*+mpPIfr zwvDJMU{)rS9GE*bpm(Lw?h|{`8@5@OxpX5sKlH+FxMX}YFjm4c%tkkOPf41tk2at*k&e#lA0w zqIATo43#DQ%{Y=tSSj`fpD)5$h&i8!o7iy;fw)H9&$BHLqLkSG8>J72{y|3 z(KCNx=?940Mi;YpmRC44?RsNrNAi{*-}qe~5gr zI&%~+z|EL#9tp$Er_qBUfoN^cHbKc|K>P{4us(6tt&Q=mkCb>?Ei?AA6C<_^XLAN= zwqUTO8Rp)0X6-=eOoo|PG<}k)H6}1IR&d=!Mts1NL)kdZ!3t<5DHp&bGC7%yJ?hW7 zMyHb=h`7)7wjsC21O>Kt4t;{>aID?|g+5}{fw^ETxIVhgq_y=zaCG`3@Z4>#3waFM zc}Z`ps;!I6`4}hYhgtm3uC5AmN7gc}I$h>CKkS_DNaiL|U+d?W3;osgU#NR=+|gpj zC(S`=kZ8*XhlPoSQVi_d8}VvH-mAZ?_l8NvG^t!Hj?#JYv}Lz@yz`w}31MsUXo-JS z@c+s7#EfZ$G0!_Y69zdHtXu^`^DkjuCN85+&;i@Z;bx0=+cL@r4=?N z8jry7G*W&Wi~#~y-fkf^MQDG>*;4;)gD|uaOFAvS9IButP>kp%dpoc_@_p-M_l!X{$9SDtf=grSk$?w2fs^qrh~DUpkEn_B)p=5fwg3RS5&3bRb9|PFM;~gp>GWNxDZ+k%CR5fe zvaDR(5El4+xt32MqlFcdxj*ii>*Mu$fjMk8P`4EMu}0@vN*b=RfT-RjR7Z=!C9Jss z^z>^Md_XcVV9KfIGoa?t>Q+Y+plC+FiJ$r-3-H_>F4GBTg#burtXhwzVrAEsUOINJ zr{)(V)0sa3gcAgq0x`q?H5RIR)GQr_E7^!f6MlX{3{FJcR-;f*0Qpde_HIE;())3? z#Gw04SZtYrrF|AHsYTk=Zb!$e6VWhp8Xt!(GBQ*Dc3NCNE_o|b(7fBoJs~?ttKAu@ zdo_3ekOW^_9)WH#M3LlkIMYnH8-D*4F*dG%Sa&AVv8b!ToKuybpAY{lLaPUmSsP>D zR|@G$DaQQBez(Z)Uk;x3C3`z@2JedOUNfFUASa$6iPCEnJgbk6Ggy)M_jJrv1o{}q z8Lv_On>F`6ksWWaxC>b`usNRC;GZdPjiB=Bjfhp8{#rTNmXywb@2Pie0%Ey_@UwII zlYUffwaw?uDI%2Ldxa$RF=4i%@PD9eFjtM3AI}^fb^-6@nb;mBqo6|~STRu4$)-Th zPTQi}7bt#**k~lDsAIQl9;#HDIbQtCZaLhVRp9nntrMdzL869j|EVV_OoZqnn$YE`r&&gYx5HUM%#hkh_yhqy6JPiKw`k~2jRA=KEhiYmDW41%=#>22uM zBnThccZPU+g2c-%k>P3@EQ8rKhV8p<%W3p^1qM#lfN_w8TV1U7dkHz6H`zl*GhoiK z8-%Shg;CHdh6|lOh{N%d{S3@NCSnOhyv<-Ag;7F2hBmV6mz!EZwUsXXRsrT+ z6o~-r(8`OTY*WZQZ_6!lTNafiRdvcu`75BSs$hG&@SgTSyEdah-GaDR)xQaBZ^wtPL2WQN6kGBy7dOFZD#R~S)TD42_^NE z()H0!t`on6;9PGdvy@CFgf-H7^EMbLX1&3o$Xqoq)fWthvVp+F?`83_<3UgaWIKLx zvjxW;;w3Es-I9o9n9VBesG=1pR7CS!NngoMnm3iYLS-EEN<=gvvNKPT|KK8e%<}MM zEuDjm&yCSU>H$u=d0fRn<1k&Ccwf~b+IyaOc1c8OljFf8DkRDYZsw_FJ)`Nq^0Zhf z0(gD!q#}112fdw$lr7$-16)-IHjPg!i3{GHMwj=A81m~B!U7n^90FthHsP>_EQPSJ z#c}}Zv2aqrW5r!|F+!Z~hSj1}Ek>uhV@&2m3q{2eFEyJ6o5}-Top)W;NT^ZWX=IDk zcB1+b~y{t=%K zT)>*?={}!&!yOT?>trpy(Ly#w523LkppB}a=^5X7$&%#>J?}=!o;iv_1+Upr^V*)p zsChT?-$e~fC@gc-o2Ls|orXqKIG288>@TB=q)a+3&GUR9O9O8ghcHDr`KqkqK5hfi z_*LLaF0L2{ehuPg4^ z@fT@Y8JjbK=m<4mV#r7Y_R)UwyEHaq_;C1^m1Lt}EWsuK+%}+OHu7!TJnvh)sQVj} z^mixYbHTW`)-A-7AU}Z<3<{*1ygCns0R&4i|0boM?*=cQ}sy1Z?2aQP{r z8Q)-nq8*8it#oT$u1*%y{SB>7o{6eNT!2T6z1X^|Y;a?&Kh{HJ^jvM@5s{Bnm@J@O z?swazHKK+6>;OK($)F|q_R>p`yKg5_=oDP{kGrUDZj*7Bp`K z7hYDHTdB5ZUR%(1(cQ#+Mnv0yE!zuX!3FPoz$z!{NsDa?odu8ti}{E0?}I!=#`cyP zuDpiWfgonY|NA6gO!fzv`zR~xa%gV0ws)stY0SJ7>U6c!nG-xg$KjS3&W1MNy=r^&o zfm$s)@`_0N%ze5P*KRuhQq!YHQCV@*O8!GbwL_TWc|3_sZ(Y@lCWL}1Q>AHpw)NJP0PLrNivuFpK z;=TwhGf0k8vTCDo>iC05LY`qi1$*a16-F}ivLjO-P4=CaHh+T0@qa`WPi;XQE*&O8 zIo+;**K;@(;6&Mu>ytZUi4f)v*!=At@jO#>$4P*zBrm(8S8)`}YpVrBsm-iVr81cox zx}mzT8xY5e0`1PE#ZJP5Ac}9PmG6Oi#m|VB1Qh0e0G&&@xp*#OYOm>C+D1?l)A zCsE+5eqky5?HCz7eL0{mlYi_{u0$Kp+J*sf3d&MN=D zqJxd7QQP>V5S?Q}X7bU~1O&Pil)X%;A*m8sRNw-y);lz}Fcu!-`(FESDmU<^?fLzG zND8E*{e&l;i5I2FNTv-9h2o&NXlfJxadc1&4~ROB)zWTAKKXh_iQc<^qfcO-rC*se z4j5u#$JVdCSrRf;`s?S_8hw{}%%^osnwN}cSOxX@_@hou3x(+llTKu_ZFDJ+`TS*# z-=u-7WxaCz+NwwYH7p{+B~P3}CLRJl7q<>HG_Tm&T=w3`XQO_hHSNOPTa3J~kKv}U zw!npqfEZ25lg5<+nHIU2jSjW57Usu+Lgg5=N85nZzl9c{JSyD!;jdcjK3y(C^~02) zl@6Rcw(O8lzhfPp*tX9A886BenWbaj}|Wgb`esF8$^W#m?aM+gKR6A|>h(2s-4rImg0hz4i zMmyU%cG)9w@`M-Fq-U@eIY|BG?o9$XfgIcCaJeF+hd?rH1Q&OQ;gMw8ov-Qv*>Wev zsY%t8n_J?Qgm_Q!OILlyfuALLc}ov9a3H@mg?g%qM+uo4P^!9;ioF_Qe~h zK7eKUXk_i3XH*mE8i13~LXn~%O*%+hN&x8~ks=_hNTjJCZ6QGD9TgM0NRtjq5CoLo zdqX!=bJM*nX#4pSuP{= z;}oxrr37wMZ}IQD0^y z|FWaz)kiK`;me&;wN!SW*FPR{XEEc;vJ*8n5WFda3+5#$eP_nucAe!x@-=;n_=Y*~ zR8cy6WF?m=D*Gc2r4)SSsKFChT*B6n8rCBX<}Ms6+>t$;i`4Q!Fjoq2^B{E*_>cP!-7nqj)juU$7@kx699JC0iq*DxEiQB=4E%iJ+!nYnV} zc=9KV@e2kfm7!1K9Wt$()NR()AB<*h77wy=Ssp2oUA{VV8f*GuwN-0QYunfUWb#7% zHG6FAlXuE&&vN|+$J`(Jp0BlCQmJOdyQasDA;IZF8hb-;&a{O`Xk^|^LiyG{VDU3#f!4-bW* zm`pQx*wvajPjCNLmf?ev_ed1CAP5$b9m_ha9#g>07!xdD;V+wI$Ii}RrKz+a5}Xtf zu>P{hNLg(jT4n62;ofO2?M%k3$c1aaINLuTaNRCx!giKw`#dGXsG($Mog51j!*p2N zVe6}i{xc?ABtwNHGi0$d$6w_TsK9UMD~;B;+|D=KPjm`BAi)+ghvf%7c}LGp-XWPI zsN5|=5nN3;LHd#o@rcS!;!epB?%uMXHnl{eop#o5CgTqsKY4ndKzNVsK!1A-p?meq z3#&|+bST4(2i4hwgbo{Wz7RP_R5!+giw7@%jBzy3NWh68KF|saK(M6gOw8muIU_Rt=)!h@SGxZnB_TXlM zo=cJq)=5pl(_7(2*7dUJSYaB=OJW@Ny=2K)9-$fz8_6$ZB zu*UjT_JHlQ3i>OHr8*MzdaL=^f>|zwDs9~wvGj-=>Mv$o6c|tz7Q3|>u(SZK*_d39 z?`j*3aHb=-Ykj#;p4zoS%{(iid~{BSBHq&j^{N;mJ*ev-_VRVr)ykm9x!{4HbK?11 zCl~!#t9Qcoid6Cx=(=uIZokW8h!VV%Z`PA$z^a&A^D z{(^+eXt*O!%fO9#Grs@zV*HlE9pl15Oga|c5x8Z~M5&>$Yt*Myo1QUI>^e_a)2$j` zz_dT}w6}P6&hWIp8fqGw`9TGysK85ZMesjPp3~1*yJw|NdpkbmFiJuoXHMHRMf=gB zYAVjRx-}t0SSKrmu5F+WPtnCNXL?rG=qRUD^vzo|+mA;m?ziwJZcfrZ;!~SHsTw>a zu?S@hvvtIvVF5}aUNNRQhKtQ+M-Gz~?9IODvDl7yFCIE#5)V_N6?Q1l3KFf7=99i3 zsM+0j8}&M9t#{5v@Z6=Yo}k$);;B;A1cL$a(hdcDJpmK zaqG>$Nfr_w9^e@?d)_=9r1~@ahoGB@9v@Vtp5u=&8QF-=;sQV3%)Noucw?XI6vba+ zFojL4%@1TB2s;ypYktZ(rQzyRtF~cn}FAi#^6+2P5 zq(IsbDKDbQ9-M$5n}X-7)6>YiL|w##H$mrIqyz+W+#mr}%d_wsBhi;#P+Q}Y7I^)_ zYXOlN9;+22y(gq|KpZOTnoO!%dkYvXJyX>|FViyYZH#9jrgwOB@nVCLkaMuL?T$+x zQ>6H55}_eYy^~U08?JdSwd0Ko=5#s0&(2`|)lc@1ibMm}Q{F2KEDmeG)Wi0Lxk|h> z1|D*7F!fm@lVqHB%U=>>|G8ak2Z11@@N|BZdh2d}G6?z*x(13k)74b0oRrmYJ)@pJ zHLkpGS!6&&F@>gN*A%TxTV_*nD9IH=gKu(*ce%P;Muxu(DvAdqE=M_%o(|`czVstaK)o#|W-U7H3Pp6$FaZ zP3vswF?Xa?@-K99l3071disLjm2e?S(mFjID&=L@+~I1@W4l6wRJOV9XOZXY=_b#P z-_fQ+hP0FRR!kMdNprxqCqaARuwyzOJtZ~5?P}~8RZ>^{s811AZOxI+i;>e;OW8hF zZ7H|qP0D-Ruv9T*7l(nQ1Pwf2vDc&MmFKHMD9hx!y7TCLPS$Nv1k@X^duhaUy!3In zG5OqUu%X2cNn#bxt+g(r64rMYWB`213SqII@7JxQ8i_T?u{R1~(Ic~bqFrlz zt}QZMv_SwVNF9z1(`5KbVh_YY`tTav?9ySpr|g{v=M`{=Iy=bSFUP!RgX(ulV*Op* zex)g%C{a__FzsH@eCCX%FrbBkK!yB=-T=dhfw8&7A`++W)tGoKaV6yER~Xq69yZp; z>d`v9W@Aqj(Wk*cy*ar_xb_q76Kf^-bbDE#Zmscz4^OtNZf4ZcbKI0Ro6+>FFj zkgE}kCBzLRNWjyJfMTJ3FBY%^e0O8eF))B~3#{(owup`{1>Vy$Vo(9(kk|n!a&T;q zCe!D+L2vqL)Xr1-DWs_)95cL*@=_ZU(s{B;2XJkaapM9li<;|(ZGmSmYJ))Jxj-s# zD&G+2BiJ3}B9QN0CGyrc>AHCgRqL4?4H8p65efrQdI4M|k&g5Z5HA46F8*mcwq>~H zciuraQ8Q!nXks9Q6A0vjr{n)T4Pf8FhJXVhQt(XLuV6b&BRb3RCE_JE;0&)-<#r{{ ziG#FdVlxSVGsXT$?9*~TS#Dy4iLg4?2QUH$(FY7m43zjm)Y~%OKvmmEHTOli+eHXP2 z7#?T)!7Dkb{N%;M&R|iU-iu-_5LFLq+eS{w>VPxqm-RG^_pIE|&feRfPoalm1=A zTCl%dG3tr#?aHhD0hO{2KotdJyNOF5&=b2RnF$+&24!Jk|HaL9{^|Os<`q<8J?5$V IY1G3101CFh<^TWy literal 0 HcmV?d00001 diff --git a/enterprise-audit-signal-router/docs/demo.svg b/enterprise-audit-signal-router/docs/demo.svg new file mode 100644 index 0000000..8e39ff3 --- /dev/null +++ b/enterprise-audit-signal-router/docs/demo.svg @@ -0,0 +1,33 @@ + + Enterprise Audit Signal Router Demo + Dashboard, signed webhooks, and export packets for SCIBASE enterprise tooling. + + Enterprise Audit Signal Router + Admin dashboards • API/webhooks • export pipelines + + + Admin Dashboard + 2 projects + 50 logins + 19 AI reviews + 1 compliance risk + + + + Signed Webhooks + project.created + publication.released + review.completed + HMAC + scoped APIs + + + + Export Packets + arXiv / Zenodo + PubMed / NIH + DOI + ORCID + version history + + + Requirement map: visibility, analytics, usage, productivity, compliance, integrations, webhooks, repository/journal/funder exports. + diff --git a/enterprise-audit-signal-router/package.json b/enterprise-audit-signal-router/package.json new file mode 100644 index 0000000..ec0cc24 --- /dev/null +++ b/enterprise-audit-signal-router/package.json @@ -0,0 +1,12 @@ +{ + "name": "enterprise-audit-signal-router", + "version": "0.1.0", + "description": "Dependency-free enterprise tooling module for admin dashboards, secure APIs/webhooks, and research export pipelines.", + "type": "module", + "private": true, + "scripts": { + "check": "npm test && npm run demo", + "demo": "node src/cli.js", + "test": "node --test test/*.test.js" + } +} diff --git a/enterprise-audit-signal-router/sample/enterprise-fixture.json b/enterprise-audit-signal-router/sample/enterprise-fixture.json new file mode 100644 index 0000000..29ab69f --- /dev/null +++ b/enterprise-audit-signal-router/sample/enterprise-fixture.json @@ -0,0 +1,63 @@ +{ + "institution": { + "id": "midlands-research-office", + "name": "Midlands Research Office", + "departments": ["Bioengineering", "Climate Systems", "Materials Lab"], + "apiClients": [ + {"id": "dspace-sync", "system": "DSpace", "scopes": ["projects:read", "exports:write"]}, + {"id": "canvas-lms", "system": "Canvas", "scopes": ["projects:read", "reviews:read"]}, + {"id": "orcid-hris", "system": "ORCID-HRIS", "scopes": ["contributors:sync"]} + ], + "webhookSecret": "institutional-secret" + }, + "contributors": [ + {"id": "r-ada", "name": "Ada Kim", "department": "Bioengineering", "orcid": "0000-0002-1111-2222", "lab": "Bio-AI", "logins": 22}, + {"id": "r-linus", "name": "Linus Park", "department": "Climate Systems", "orcid": "0000-0003-3333-4444", "lab": "Climate Risk", "logins": 17}, + {"id": "r-marie", "name": "Marie Choi", "department": "Materials Lab", "orcid": "0000-0001-5555-6666", "lab": "NanoLab", "logins": 11} + ], + "projects": [ + { + "id": "proj-open-catalyst", + "title": "Open Catalyst Reproducibility", + "visibility": "public", + "department": "Materials Lab", + "lab": "NanoLab", + "tags": ["GRANT-TRACKED", "DOCTORAL WORK"], + "contributors": ["r-marie", "r-ada"], + "storageGb": 184, + "computeHours": 96, + "submissions": 4, + "aiReviews": 8, + "peerReviews": 6, + "funderMandates": ["NIH data sharing"], + "openAccess": true, + "reproducibilityScore": 0.91, + "doi": "10.5555/scibase.catalyst.2026", + "citations": ["Kim A, Choi M. Open catalyst reproducibility. 2026."], + "versions": ["v0.1 protocol", "v1.0 preprint"], + "events": ["project.created", "review.completed", "publication.released"] + }, + { + "id": "proj-private-climate", + "title": "Private Climate Model Review", + "visibility": "private", + "department": "Climate Systems", + "lab": "Climate Risk", + "tags": ["BUSINESS-UNIT-RD"], + "contributors": ["r-linus"], + "storageGb": 640, + "computeHours": 420, + "submissions": 2, + "aiReviews": 11, + "peerReviews": 1, + "funderMandates": ["Horizon EU open data"], + "openAccess": false, + "reproducibilityScore": 0.68, + "doi": null, + "citations": ["Park L. Private climate model review. 2026."], + "versions": ["internal-draft"], + "events": ["project.created", "review.completed"] + } + ], + "usageWindow": "2026-05" +} diff --git a/enterprise-audit-signal-router/src/cli.js b/enterprise-audit-signal-router/src/cli.js new file mode 100644 index 0000000..7c1b419 --- /dev/null +++ b/enterprise-audit-signal-router/src/cli.js @@ -0,0 +1,19 @@ +#!/usr/bin/env node +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { buildEnterpriseToolingBrief } from './index.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const fixturePath = path.join(__dirname, '..', 'sample', 'enterprise-fixture.json'); +const fixture = JSON.parse(fs.readFileSync(fixturePath, 'utf8')); +const brief = buildEnterpriseToolingBrief(fixture); + +console.log('Enterprise Audit Signal Router Demo'); +console.log(`Institution: ${brief.institution}`); +console.log(`Projects: ${brief.dashboard.projectCounts.total} (${brief.dashboard.projectCounts.public} public / ${brief.dashboard.projectCounts.private} private)`); +console.log(`Top researcher: ${brief.dashboard.contributorAnalytics.topResearchers[0].name}`); +console.log(`Compliance risks: ${brief.dashboard.complianceTracking.filter((item) => item.status === 'attention-required').length}`); +console.log(`Webhook deliveries: ${brief.routedEvents.reduce((sum, route) => sum + route.deliveries.length, 0)}`); +console.log(`Export packets: ${brief.exportPackets.length}`); +console.log(`Audit hash: ${brief.auditHash.slice(0, 16)}`); diff --git a/enterprise-audit-signal-router/src/index.js b/enterprise-audit-signal-router/src/index.js new file mode 100644 index 0000000..cb83e5b --- /dev/null +++ b/enterprise-audit-signal-router/src/index.js @@ -0,0 +1,287 @@ +import crypto from 'node:crypto'; + +const DEFAULT_EVENT_ROUTES = { + 'project.created': ['DSpace', 'Canvas', 'HRIS'], + 'publication.released': ['DSpace', 'Zenodo', 'PubMed Central', 'funder-portal'], + 'review.completed': ['Canvas', 'ELN', 'reporting-platform'] +}; + +const EXPORT_TARGETS = { + arxiv: { format: 'LaTeX', mandate: 'preprint-ready manuscript' }, + biorxiv: { format: 'JATS', mandate: 'life-science preprint deposit' }, + zenodo: { format: 'DataCite JSON-LD', mandate: 'dataset and software DOI deposit' }, + 'pubmed-central': { format: 'JATS XML', mandate: 'public-access manuscript archive' }, + 'nih-reporter': { format: 'grant report packet', mandate: 'NIH progress reporting' }, + ukri: { format: 'funder compliance CSV', mandate: 'UKRI open research reporting' } +}; + +function stableStringify(value) { + if (Array.isArray(value)) { + return `[${value.map(stableStringify).join(',')}]`; + } + if (value && typeof value === 'object') { + return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`).join(',')}}`; + } + return JSON.stringify(value); +} + +function digest(value) { + return crypto.createHash('sha256').update(stableStringify(value)).digest('hex'); +} + +function signPayload(payload, secret) { + return crypto.createHmac('sha256', secret || 'scibase-enterprise').update(stableStringify(payload)).digest('hex'); +} + +function indexById(items = []) { + return new Map(items.map((item) => [item.id, item])); +} + +export function normalizeEnterpriseWorkspace(input) { + const institution = input.institution || {}; + const contributors = (input.contributors || []).map((contributor) => ({ + logins: 0, + ...contributor, + department: contributor.department || 'Unassigned', + lab: contributor.lab || 'Unassigned' + })); + const knownContributors = indexById(contributors); + const projects = (input.projects || []).map((project) => ({ + visibility: 'private', + tags: [], + contributors: [], + storageGb: 0, + computeHours: 0, + submissions: 0, + aiReviews: 0, + peerReviews: 0, + funderMandates: [], + openAccess: false, + reproducibilityScore: 0, + citations: [], + versions: [], + events: [], + ...project, + contributorDetails: (project.contributors || []).map((id) => knownContributors.get(id)).filter(Boolean) + })); + + return { + institution: { + id: institution.id || 'enterprise-institution', + name: institution.name || 'Enterprise Institution', + departments: institution.departments || [], + apiClients: institution.apiClients || [], + webhookSecret: institution.webhookSecret || 'scibase-enterprise' + }, + contributors, + projects, + usageWindow: input.usageWindow || 'current' + }; +} + +export function evaluateCompliance(project) { + const missing = []; + if ((project.funderMandates || []).length === 0) missing.push('funder mandate mapping'); + if (!project.openAccess) missing.push('open access confirmation'); + if ((project.reproducibilityScore || 0) < 0.8) missing.push('reproducibility score >= 0.80'); + if (!project.doi && project.visibility === 'public') missing.push('DOI for public output'); + + return { + projectId: project.id, + openAccess: Boolean(project.openAccess), + funderMandates: project.funderMandates || [], + reproducibilityScore: Number(project.reproducibilityScore || 0), + status: missing.length === 0 ? 'compliant' : 'attention-required', + missing, + flags: missing.map((item) => `COMPLIANCE:${item.toUpperCase()}`) + }; +} + +export function buildAdminDashboard(input) { + const workspace = normalizeEnterpriseWorkspace(input); + const projectCounts = workspace.projects.reduce((acc, project) => { + acc.total += 1; + acc[project.visibility] = (acc[project.visibility] || 0) + 1; + acc.byDepartment[project.department] = (acc.byDepartment[project.department] || 0) + 1; + for (const tag of project.tags) acc.tags[tag] = (acc.tags[tag] || 0) + 1; + return acc; + }, { total: 0, public: 0, private: 0, byDepartment: {}, tags: {} }); + + const contributorStats = workspace.contributors.map((contributor) => { + const projects = workspace.projects.filter((project) => project.contributors.includes(contributor.id)); + const labs = new Set(projects.map((project) => project.lab)); + return { + id: contributor.id, + name: contributor.name, + department: contributor.department, + activityHeat: contributor.logins + projects.reduce((sum, project) => sum + project.submissions + project.aiReviews + project.peerReviews, 0), + projectCount: projects.length, + crossLabCollaborations: Math.max(0, labs.size - 1), + orcid: contributor.orcid + }; + }).sort((a, b) => b.activityHeat - a.activityHeat || a.name.localeCompare(b.name)); + + const usage = workspace.projects.reduce((acc, project) => { + acc.logins = workspace.contributors.reduce((sum, contributor) => sum + contributor.logins, 0); + acc.submissions += project.submissions; + acc.storageGb += project.storageGb; + acc.computeHours += project.computeHours; + return acc; + }, { logins: 0, submissions: 0, storageGb: 0, computeHours: 0 }); + + const productivity = workspace.projects.reduce((acc, project) => { + acc.projectsPerLab[project.lab] = (acc.projectsPerLab[project.lab] || 0) + 1; + acc.aiReviewsGenerated += project.aiReviews; + acc.peerReviewsCompleted += project.peerReviews; + return acc; + }, { projectsPerLab: {}, aiReviewsGenerated: 0, peerReviewsCompleted: 0 }); + + const compliance = workspace.projects.map(evaluateCompliance); + + return { + institution: workspace.institution.name, + usageWindow: workspace.usageWindow, + projectCounts, + contributorAnalytics: { + topResearchers: contributorStats.slice(0, 5), + activityHeatmap: contributorStats.map(({ id, activityHeat }) => ({ contributorId: id, heat: activityHeat })), + crossLabCollaborations: contributorStats.filter((stat) => stat.crossLabCollaborations > 0) + }, + usageStats: usage, + productivityMetrics: productivity, + complianceTracking: compliance, + customTags: projectCounts.tags, + dashboardHash: digest({ projectCounts, contributorStats, usage, productivity, compliance }) + }; +} + +export function buildApiCatalog(input) { + const workspace = normalizeEnterpriseWorkspace(input); + const clientSystems = new Set(workspace.institution.apiClients.map((client) => client.system)); + const integrations = ['DSpace', 'Invenio', 'Canvas', 'Moodle', 'ELN', 'lab-inventory', 'HRIS', 'ORCID'].map((system) => ({ + system, + configured: clientSystems.has(system) || [...clientSystems].some((client) => client.toLowerCase().includes(system.toLowerCase())), + auth: 'signed service token with scoped API key rotation' + })); + + return { + restEndpoints: [ + { method: 'GET', path: '/api/enterprise/projects', scope: 'projects:read', purpose: 'list public/private hosted projects for dashboards and archives' }, + { method: 'GET', path: '/api/enterprise/contributors', scope: 'contributors:sync', purpose: 'sync ORCID/HRIS personnel metadata' }, + { method: 'POST', path: '/api/enterprise/exports', scope: 'exports:write', purpose: 'create export packets for repositories, journals, and funders' }, + { method: 'GET', path: '/api/enterprise/reviews', scope: 'reviews:read', purpose: 'share reproducibility scores and peer-review status with LMS/ELN systems' } + ], + webhookEvents: Object.keys(DEFAULT_EVENT_ROUTES).map((event) => ({ event, destinations: DEFAULT_EVENT_ROUTES[event], signatureHeader: 'X-SCIBASE-Signature-256' })), + integrations, + apiClients: workspace.institution.apiClients, + securityControls: ['HMAC webhook signatures', 'least-privilege scopes', 'idempotency keys', 'PII-minimized payloads', 'audit hashes'] + }; +} + +export function routeEnterpriseEvents(input, events = []) { + const workspace = normalizeEnterpriseWorkspace(input); + const projects = indexById(workspace.projects); + const secret = workspace.institution.webhookSecret; + + return events.map((event) => { + const project = projects.get(event.projectId); + const payload = { + event: event.type, + projectId: event.projectId, + title: project?.title || 'Unknown project', + visibility: project?.visibility || 'unknown', + department: project?.department || 'unknown', + reproducibilityScore: project?.reproducibilityScore ?? null, + doi: project?.doi || null, + occurredAt: event.occurredAt || '2026-05-15T00:00:00.000Z' + }; + const destinations = DEFAULT_EVENT_ROUTES[event.type] || ['reporting-platform']; + return { + eventId: event.id || digest(payload).slice(0, 12), + type: event.type, + destinations, + deliveries: destinations.map((destination) => ({ + destination, + method: 'POST', + url: `https://integrations.example/${destination.toLowerCase().replace(/\s+/g, '-')}/webhooks/scibase`, + payload, + headers: { + 'Content-Type': 'application/json', + 'X-SCIBASE-Event': event.type, + 'X-SCIBASE-Signature-256': signPayload({ destination, payload }, secret) + } + })) + }; + }); +} + +export function buildExportPacket(input, projectId, requestedTargets = ['zenodo', 'pubmed-central', 'nih-reporter']) { + const workspace = normalizeEnterpriseWorkspace(input); + const project = workspace.projects.find((item) => item.id === projectId); + if (!project) throw new Error(`Unknown project: ${projectId}`); + const contributorLookup = indexById(workspace.contributors); + const contributors = project.contributors.map((id) => contributorLookup.get(id)).filter(Boolean).map((contributor) => ({ + name: contributor.name, + orcid: contributor.orcid, + department: contributor.department + })); + const targets = requestedTargets.map((target) => ({ + target, + ...(EXPORT_TARGETS[target] || { format: 'custom package', mandate: 'custom institutional export' }) + })); + + return { + projectId: project.id, + title: project.title, + visibility: project.visibility, + targets, + manuscriptFormats: [...new Set(targets.map((target) => target.format))], + metadata: { + doi: project.doi, + orcids: contributors.map((contributor) => contributor.orcid).filter(Boolean), + citations: project.citations, + versionHistory: project.versions, + tags: project.tags, + funderMandates: project.funderMandates + }, + compliance: evaluateCompliance(project), + files: targets.map((target) => `${project.id}/${target.target}.${target.format.toLowerCase().replace(/[^a-z0-9]+/g, '-')}`), + packetHash: digest({ project, contributors, targets }) + }; +} + +export function buildEnterpriseToolingBrief(input) { + const workspace = normalizeEnterpriseWorkspace(input); + const dashboard = buildAdminDashboard(workspace); + const apiCatalog = buildApiCatalog(workspace); + const events = workspace.projects.flatMap((project) => project.events.map((type, index) => ({ + id: `${project.id}-${type}-${index}`, + type, + projectId: project.id, + occurredAt: `2026-05-${String(10 + index).padStart(2, '0')}T12:00:00.000Z` + }))); + const routedEvents = routeEnterpriseEvents(workspace, events); + const exportPackets = workspace.projects.map((project) => buildExportPacket(workspace, project.id)); + + const requirementMap = { + adminDashboards: ['project visibility counts', 'contributor heatmaps', 'usage stats', 'productivity metrics', 'compliance tracking', 'custom tags'], + apiAndWebhooks: ['secure REST endpoints', 'DSpace/Invenio/LMS/ELN/HRIS/ORCID integration catalog', 'signed project/publication/review webhooks'], + exportPipelines: ['preprint and repository targets', 'journal/funder formats', 'DOI/ORCID/citation/version preservation'] + }; + + return { + institution: workspace.institution.name, + dashboard, + apiCatalog, + routedEvents, + exportPackets, + requirementMap, + acceptanceEvidence: [ + `${dashboard.projectCounts.total} projects summarized across public/private visibility`, + `${dashboard.complianceTracking.filter((item) => item.status === 'attention-required').length} compliance risks flagged`, + `${routedEvents.reduce((sum, route) => sum + route.deliveries.length, 0)} signed webhook deliveries prepared`, + `${exportPackets.length} export packets generated with preserved DOI/ORCID/citation metadata` + ], + auditHash: digest({ dashboard, apiCatalog, routedEvents, exportPackets, requirementMap }) + }; +} diff --git a/enterprise-audit-signal-router/test/enterprise-audit-signal-router.test.js b/enterprise-audit-signal-router/test/enterprise-audit-signal-router.test.js new file mode 100644 index 0000000..28902b6 --- /dev/null +++ b/enterprise-audit-signal-router/test/enterprise-audit-signal-router.test.js @@ -0,0 +1,96 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + buildAdminDashboard, + buildApiCatalog, + buildEnterpriseToolingBrief, + buildExportPacket, + evaluateCompliance, + routeEnterpriseEvents +} from '../src/index.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const fixture = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'sample', 'enterprise-fixture.json'), 'utf8')); + +test('admin dashboard covers visibility, contributor analytics, usage, productivity, compliance, and custom tags', () => { + const dashboard = buildAdminDashboard(fixture); + + assert.equal(dashboard.projectCounts.total, 2); + assert.equal(dashboard.projectCounts.public, 1); + assert.equal(dashboard.projectCounts.private, 1); + assert.equal(dashboard.customTags['GRANT-TRACKED'], 1); + assert.equal(dashboard.usageStats.logins, 50); + assert.equal(dashboard.usageStats.submissions, 6); + assert.equal(dashboard.productivityMetrics.aiReviewsGenerated, 19); + assert.equal(dashboard.productivityMetrics.peerReviewsCompleted, 7); + assert.equal(dashboard.contributorAnalytics.topResearchers[0].name, 'Ada Kim'); + assert.equal(dashboard.complianceTracking.some((entry) => entry.status === 'attention-required'), true); + assert.match(dashboard.dashboardHash, /^[a-f0-9]{64}$/); +}); + +test('compliance evaluator flags funder, open-access, and reproducibility gaps', () => { + const privateProject = fixture.projects.find((project) => project.id === 'proj-private-climate'); + const compliance = evaluateCompliance(privateProject); + + assert.equal(compliance.status, 'attention-required'); + assert.ok(compliance.missing.includes('open access confirmation')); + assert.ok(compliance.missing.includes('reproducibility score >= 0.80')); + assert.ok(compliance.flags.every((flag) => flag.startsWith('COMPLIANCE:'))); +}); + +test('API catalog includes scoped REST endpoints, webhooks, integrations, and security controls', () => { + const catalog = buildApiCatalog(fixture); + + assert.ok(catalog.restEndpoints.find((endpoint) => endpoint.path === '/api/enterprise/projects')); + assert.ok(catalog.webhookEvents.find((event) => event.event === 'publication.released')); + assert.ok(catalog.integrations.find((integration) => integration.system === 'DSpace' && integration.configured)); + assert.ok(catalog.integrations.find((integration) => integration.system === 'Canvas' && integration.configured)); + assert.ok(catalog.integrations.find((integration) => integration.system === 'ORCID' && integration.configured)); + assert.ok(catalog.securityControls.includes('HMAC webhook signatures')); + assert.ok(catalog.securityControls.includes('least-privilege scopes')); +}); + +test('event router prepares signed webhook deliveries for project, publication, and review events', () => { + const routes = routeEnterpriseEvents(fixture, [ + { type: 'project.created', projectId: 'proj-open-catalyst', occurredAt: '2026-05-10T12:00:00.000Z' }, + { type: 'publication.released', projectId: 'proj-open-catalyst', occurredAt: '2026-05-11T12:00:00.000Z' }, + { type: 'review.completed', projectId: 'proj-private-climate', occurredAt: '2026-05-12T12:00:00.000Z' } + ]); + + assert.equal(routes.length, 3); + assert.deepEqual(routes[0].destinations, ['DSpace', 'Canvas', 'HRIS']); + assert.ok(routes[1].deliveries.find((delivery) => delivery.destination === 'Zenodo')); + assert.equal(routes[2].deliveries[0].headers['X-SCIBASE-Event'], 'review.completed'); + assert.match(routes[2].deliveries[0].headers['X-SCIBASE-Signature-256'], /^[a-f0-9]{64}$/); +}); + +test('export packet preserves DOI, ORCID, citation, version history, journal, repository, and funder targets', () => { + const packet = buildExportPacket(fixture, 'proj-open-catalyst', ['arxiv', 'zenodo', 'nih-reporter']); + + assert.equal(packet.metadata.doi, '10.5555/scibase.catalyst.2026'); + assert.ok(packet.metadata.orcids.includes('0000-0002-1111-2222')); + assert.ok(packet.metadata.citations[0].includes('Open catalyst')); + assert.ok(packet.metadata.versionHistory.includes('v1.0 preprint')); + assert.ok(packet.targets.find((target) => target.target === 'arxiv' && target.format === 'LaTeX')); + assert.ok(packet.targets.find((target) => target.target === 'zenodo' && target.format === 'DataCite JSON-LD')); + assert.ok(packet.targets.find((target) => target.target === 'nih-reporter' && target.format === 'grant report packet')); + assert.equal(packet.compliance.status, 'compliant'); + assert.match(packet.packetHash, /^[a-f0-9]{64}$/); +}); + +test('enterprise brief maps every issue capability to concrete dashboard, integration, webhook, and export evidence', () => { + const brief = buildEnterpriseToolingBrief(fixture); + + assert.equal(brief.requirementMap.adminDashboards.length, 6); + assert.equal(brief.requirementMap.apiAndWebhooks.length, 3); + assert.equal(brief.requirementMap.exportPipelines.length, 3); + assert.ok(brief.acceptanceEvidence.some((item) => item.includes('projects summarized'))); + assert.ok(brief.acceptanceEvidence.some((item) => item.includes('signed webhook deliveries'))); + assert.ok(brief.acceptanceEvidence.some((item) => item.includes('export packets generated'))); + assert.ok(brief.routedEvents.length >= 5); + assert.equal(brief.exportPackets.length, 2); + assert.match(brief.auditHash, /^[a-f0-9]{64}$/); +});