diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7056fc3..dd8cb1f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5,7 +5,7 @@ settings: excludeLinksFromLockfile: false overrides: - '@babel/plugin-transform-modules-systemjs': '>=7.29.4' + '@babel/plugin-transform-modules-systemjs': '>=7.29.4 <8' '@react-native-community/cli': '>=17.0.1' '@react-native-community/cli-server-api': '>=17.0.1' '@ungap/structured-clone': '>=1.3.1' @@ -14,6 +14,7 @@ overrides: brace-expansion@2: '>=2.0.3 <3' brace-expansion@5: '>=5.0.6 <6' fast-xml-parser: '>=4.5.5' + js-yaml@3: '>=3.15.0' minimatch@3: '>=3.1.5 <4' minimatch@5: '>=5.1.9 <6' minimatch@9: '>=9.0.9 <10' @@ -25,10 +26,14 @@ overrides: postcss: '>=8.5.10' semver@5: '>=7.7.4' semver@6: '>=7.7.4' + shell-quote: '>=1.8.4' tar: '>=7.5.13' + tmp: '>=0.2.6' undici: '>=6.24.1 <7' uuid: '>=11.0.0' - ws@8: '>=8.20.1' + ws@6: '>=6.2.4' + ws@7: '>=7.5.11' + ws@8: '>=8.21.0' yaml@1: '>=1.10.3 <2' yaml@2: '>=2.8.3 <3' @@ -125,10 +130,6 @@ packages: resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} engines: {node: '>=6.9.0'} - '@babel/code-frame@8.0.0': - resolution: {integrity: sha512-dYYg153EyN2Ekbqw2zAsbd6/JR+9N2SEoC7YV2GyyqMM7x9bLDTjBD6XBhSMLH0wtIVyJj03jWNriQhaN+eoCw==} - engines: {node: ^22.18.0 || >=24.11.0} - '@babel/compat-data@7.29.7': resolution: {integrity: sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==} engines: {node: '>=6.9.0'} @@ -141,10 +142,6 @@ packages: resolution: {integrity: sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==} engines: {node: '>=6.9.0'} - '@babel/generator@8.0.0': - resolution: {integrity: sha512-NT9NrVwJsbSV6Y2FSstWa71EETOnzrjkL5/wX3D2mYHtKM+qvqB1DvR4D0Setb/gDBsHzRICifwEWMO8CnTF6g==} - engines: {node: ^22.18.0 || >=24.11.0} - '@babel/helper-annotate-as-pure@7.29.7': resolution: {integrity: sha512-OoK6239jHPuSQOoS0kfTVKn0b/rVTk0seKq4Gd2UMLtmOVLjDC0ki3e+c90Trqv2gMfvJFqkiljrr568+qddiw==} engines: {node: '>=6.9.0'} @@ -174,10 +171,6 @@ packages: resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==} engines: {node: '>=6.9.0'} - '@babel/helper-globals@8.0.0': - resolution: {integrity: sha512-lLozHOM6sWWlxNo8CYqHy4MBZeTvHXNgVPBfPOGsjPKUzHC2Az9QwB6gxdQmpwHl6GlQtbGgS+lj5887guDiLw==} - engines: {node: ^22.18.0 || >=24.11.0} - '@babel/helper-member-expression-to-functions@7.29.7': resolution: {integrity: sha512-j+7JYmk1JYDtACIGj0QJqqWZjoUpMoEikQGADMaHgCMCSDqd2+P32rfcibUNrGOMWrlzK1WJBdxrB3JJQZwWtg==} engines: {node: '>=6.9.0'} @@ -186,22 +179,12 @@ packages: resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==} engines: {node: '>=6.9.0'} - '@babel/helper-module-imports@8.0.0': - resolution: {integrity: sha512-NZ7mSS93o4ndX4KrbD7W8Sf3QT8Qe24PrnFyUcuOPDzK6faqDFKjY9RG7he7+I7FdiQ4llpnosFqzrXa+Vy3Ew==} - engines: {node: ^22.18.0 || >=24.11.0} - '@babel/helper-module-transforms@7.29.7': resolution: {integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==} engines: {node: '>=6.9.0'} peerDependencies: '@babel/core': ^7.0.0 - '@babel/helper-module-transforms@8.0.1': - resolution: {integrity: sha512-UgAhl1kqiW5ciE0yCXqqvnb4H2n3IELJ7lIIQRezwDPilPEZX5i+Rvbja9MFTkwUn2biEiSMeV31aUzR4Lwakw==} - engines: {node: ^22.18.0 || >=24.11.0} - peerDependencies: - '@babel/core': ^8.0.0 - '@babel/helper-optimise-call-expression@7.29.7': resolution: {integrity: sha512-+kmGVjcT9RGYzoDwdwEqEvGgKe3BYq+O1iGzjFubaNgZHwYHP6lsF2Yghf4kEuv9BV7tYDZ913aBW9am6YKong==} engines: {node: '>=6.9.0'} @@ -210,12 +193,6 @@ packages: resolution: {integrity: sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==} engines: {node: '>=6.9.0'} - '@babel/helper-plugin-utils@8.0.1': - resolution: {integrity: sha512-3PKFgjTyPlhFhorfP+SjKQxLViIL++zWjFOO4hGriYU+Bsm983DxEM1JmDRJVWXV0O9npu+xXRqz7Pbd3mh70g==} - engines: {node: ^22.18.0 || >=24.11.0} - peerDependencies: - '@babel/core': ^8.0.0 - '@babel/helper-remap-async-to-generator@7.29.7': resolution: {integrity: sha512-16AMiW26DbXWBbr3B8wNozKM0ydMLB892vaOaJW/fPJdnT8vJk5sdkQcU/isqUxyCE0cEoa8wZOcbgDuC4b6Og==} engines: {node: '>=6.9.0'} @@ -236,18 +213,10 @@ packages: resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} engines: {node: '>=6.9.0'} - '@babel/helper-string-parser@8.0.0': - resolution: {integrity: sha512-6mJgmFFFIIO82vvoLt9XtRC7/TkzXfts1t/SpRX4IHSzMgqoPYCWesVu1udUPUWioAE/2fcG6WuI8zrkE1gwrg==} - engines: {node: ^22.18.0 || >=24.11.0} - '@babel/helper-validator-identifier@7.29.7': resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} engines: {node: '>=6.9.0'} - '@babel/helper-validator-identifier@8.0.4': - resolution: {integrity: sha512-4wFaiLd0bVo4cIoTXI3zKI038NIWE/cr3jvBjejOVYVxV/m8Ltav1USiGzG1fmS5J2RhgEOgXNNK46cRPnRsrg==} - engines: {node: ^22.18.0 || >=24.11.0} - '@babel/helper-validator-option@7.29.7': resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==} engines: {node: '>=6.9.0'} @@ -265,11 +234,6 @@ packages: engines: {node: '>=6.0.0'} hasBin: true - '@babel/parser@8.0.4': - resolution: {integrity: sha512-srpptsAkEbbNIC/q8nT7o+m6CQe8CJUTV/t7MYc9NnWlgYVtHOb7JH6SorxMhN0kuRJjVqXbKClG6xSbPtzz+g==} - engines: {node: ^22.18.0 || >=24.11.0} - hasBin: true - '@babel/plugin-bugfix-firefox-class-in-computed-class-key@7.29.7': resolution: {integrity: sha512-j8SrR0zLZrRsC09DlszEx8FpMiwukKffYXMK0d5LmOglO7vGG6sz/BR/20yHqWH+Lnn31JTt2PE3hIWNgM2J6w==} engines: {node: '>=6.9.0'} @@ -606,11 +570,11 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 - '@babel/plugin-transform-modules-systemjs@8.0.1': - resolution: {integrity: sha512-0NEHanXmnFEnfT2dLKTXnu7m8GXFsnxRgteBC2aH21hYMBwAgxu5dcTdi/Eg+ToI1HbZe0CHwz4XRLgRNQhYoQ==} - engines: {node: ^22.18.0 || >=24.11.0} + '@babel/plugin-transform-modules-systemjs@7.29.7': + resolution: {integrity: sha512-TM2ZcQLoG2/y4HODiStCo10DibYhWhGWAwVv+EQKmG/7GFl0N+AAmUiXOMKM+aiJ9XBJ9AHVZBvTzMnJ2sM3cQ==} + engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^8.0.0 + '@babel/core': ^7.0.0-0 '@babel/plugin-transform-modules-umd@7.29.7': resolution: {integrity: sha512-B4UkaTK3QpgCwJnrxKfMPKdo92CN7OKXAlpAAnM3UPu0Q0lCCk57ylA9AJbRy2v8dDKOPAAWcoR6CMyeoHwRCA==} @@ -847,26 +811,14 @@ packages: resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} engines: {node: '>=6.9.0'} - '@babel/template@8.0.0': - resolution: {integrity: sha512-eAD0QW/AlbamBbw0FeGiwasbCVPq5ncW0HNVyLP3B9czqLyh4gvw+5JTSNt6le9+ziAU7mqDZsKTHf3jTb4chQ==} - engines: {node: ^22.18.0 || >=24.11.0} - '@babel/traverse@7.29.7': resolution: {integrity: sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==} engines: {node: '>=6.9.0'} - '@babel/traverse@8.0.4': - resolution: {integrity: sha512-bZnmqzGG8UZneG1lLxBoWIH0G6Gr1D846Yu4/3XnY6FhCndMR49u26nTY08u/dAxWmLWF9vGQOuC+84FfIUoeg==} - engines: {node: ^22.18.0 || >=24.11.0} - '@babel/types@7.29.7': resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} engines: {node: '>=6.9.0'} - '@babel/types@8.0.4': - resolution: {integrity: sha512-eY+Yn3dCqTGmyiq2QRU66lA5FL8lqqqvecHt0fF3uHONIa7ToYsaCiWV8lOKqAs0Rb2SjixiKFROngnulPtt2g==} - engines: {node: ^22.18.0 || >=24.11.0} - '@bcoe/v8-coverage@0.2.3': resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==} @@ -1059,7 +1011,7 @@ packages: '@expo/ws-tunnel@2.0.0': resolution: {integrity: sha512-j+JfTRdCk820J9dU0sA2SqshQIKFOMo7ED84w9MJFcebfbNQgsLztEY/SABDkGnjatrW4xGqnUhVRxSBVyCkXw==} peerDependencies: - ws: '>=8.20.1' + ws: '>=8.21.0' '@expo/xcpretty@4.4.4': resolution: {integrity: sha512-4aQzz9vgxcNXFfo/iyNgDDYfsU5XGKKxWxZopw0cVotHiW+U8IJbIxMaxsINs6bHhtkG3StKNPcOrn3eBuxKPw==} @@ -1420,9 +1372,6 @@ packages: '@types/jest@30.0.0': resolution: {integrity: sha512-XTYugzhuwqWjws0CVz8QpM36+T+Dz5mTEBKhNs/esGLnCIlGdRy+Dq78NRjd7ls7r8BC8ZRMOrKlkO1hU0JOwA==} - '@types/jsesc@2.5.1': - resolution: {integrity: sha512-9VN+6yxLOPLOav+7PwjZbxiID2bVaeq0ED4qSQmdQTdjnXJSaCVKTR58t15oqH1H5t8Ng2ZX1SabJVoN9Q34bw==} - '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} @@ -1763,9 +1712,6 @@ packages: arg@5.0.2: resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==} - argparse@1.0.10: - resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} - argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} @@ -2201,11 +2147,6 @@ packages: resolution: {integrity: sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} - esprima@4.0.1: - resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} - engines: {node: '>=4'} - hasBin: true - esquery@1.7.0: resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} engines: {node: '>=0.10'} @@ -2677,7 +2618,7 @@ packages: isows@1.0.7: resolution: {integrity: sha512-I1fSfDCZL5P0v33sVqeTDSpcstAg/N+wF5HS033mogOVIp4B+oHC7oOCsA3axAbBSGTJ8QubbNmnIRN/h8U7hg==} peerDependencies: - ws: '>=8.20.1' + ws: '>=6.2.4' istanbul-lib-coverage@3.2.2: resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} @@ -2849,20 +2790,17 @@ packages: jimp-compact@0.16.1: resolution: {integrity: sha512-dZ6Ra7u1G8c4Letq/B5EzAxj4tLFHL+cGtdpR+PVm4yzPDj+lCk+AbivWt1eOM+ikzkowtyV7qSqX6qr3t71Ww==} - js-tokens@10.0.0: - resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} - js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} - js-yaml@3.15.0: - resolution: {integrity: sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog==} - hasBin: true - js-yaml@4.3.0: resolution: {integrity: sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==} hasBin: true + js-yaml@5.2.1: + resolution: {integrity: sha512-zfLtNfQqxVqq3uaTqSkh4x4hZw3KHobGUA0fJUj4wawW8bsQLTVqpHdXSIzidh7o+4lEW36tANuAGdaFx6Zgnw==} + hasBin: true + jsc-safe-url@0.2.4: resolution: {integrity: sha512-0wM3YBWtYePOjfyXQH5MWQ8H7sdk5EXSwZvmSLKk2RboVQ2Bu239jycHDz5J/8Blf3K0Qnoy2b6xD+z10MFB+Q==} @@ -3233,10 +3171,6 @@ packages: resolution: {integrity: sha512-eJXMpz4aQHXF/YBB9ddqZDIS+ooO91hObo9FoW/xBkr54/zCwYYCDqT/O54vNo8kOkWs5Ou/y28NgdrV0edQNA==} engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} - obug@2.1.3: - resolution: {integrity: sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==} - engines: {node: '>=12.20.0'} - on-finished@2.3.0: resolution: {integrity: sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==} engines: {node: '>= 0.8'} @@ -3609,9 +3543,6 @@ packages: resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} engines: {node: '>=0.10.0'} - sprintf-js@1.0.3: - resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} - stack-utils@2.0.6: resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==} engines: {node: '>=10'} @@ -3916,18 +3847,6 @@ packages: resolution: {integrity: sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} - ws@7.5.11: - resolution: {integrity: sha512-zS54Oen9bITtp7kp2XM3AydrCIq1D+HwJOuH+c+e4LfpL/lotP5osijd+UoMnxwAam1GN8R4KtLAyIrIcBNpiA==} - engines: {node: '>=8.3.0'} - peerDependencies: - bufferutil: ^4.0.1 - utf-8-validate: ^5.0.2 - peerDependenciesMeta: - bufferutil: - optional: true - utf-8-validate: - optional: true - ws@8.21.0: resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==} engines: {node: '>=10.0.0'} @@ -4025,11 +3944,6 @@ snapshots: js-tokens: 4.0.0 picocolors: 1.1.1 - '@babel/code-frame@8.0.0': - dependencies: - '@babel/helper-validator-identifier': 8.0.4 - js-tokens: 10.0.0 - '@babel/compat-data@7.29.7': {} '@babel/core@7.29.7': @@ -4060,15 +3974,6 @@ snapshots: '@jridgewell/trace-mapping': 0.3.31 jsesc: 3.1.0 - '@babel/generator@8.0.0': - dependencies: - '@babel/parser': 8.0.4 - '@babel/types': 8.0.4 - '@jridgewell/gen-mapping': 0.3.13 - '@jridgewell/trace-mapping': 0.3.31 - '@types/jsesc': 2.5.1 - jsesc: 3.1.0 - '@babel/helper-annotate-as-pure@7.29.7': dependencies: '@babel/types': 7.29.7 @@ -4114,8 +4019,6 @@ snapshots: '@babel/helper-globals@7.29.7': {} - '@babel/helper-globals@8.0.0': {} - '@babel/helper-member-expression-to-functions@7.29.7': dependencies: '@babel/traverse': 7.29.7 @@ -4130,11 +4033,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/helper-module-imports@8.0.0': - dependencies: - '@babel/traverse': 8.0.4 - '@babel/types': 8.0.4 - '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 @@ -4144,23 +4042,12 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/helper-module-transforms@8.0.1(@babel/core@7.29.7)': - dependencies: - '@babel/core': 7.29.7 - '@babel/helper-module-imports': 8.0.0 - '@babel/helper-validator-identifier': 8.0.4 - '@babel/traverse': 8.0.4 - '@babel/helper-optimise-call-expression@7.29.7': dependencies: '@babel/types': 7.29.7 '@babel/helper-plugin-utils@7.29.7': {} - '@babel/helper-plugin-utils@8.0.1(@babel/core@7.29.7)': - dependencies: - '@babel/core': 7.29.7 - '@babel/helper-remap-async-to-generator@7.29.7(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 @@ -4188,12 +4075,8 @@ snapshots: '@babel/helper-string-parser@7.29.7': {} - '@babel/helper-string-parser@8.0.0': {} - '@babel/helper-validator-identifier@7.29.7': {} - '@babel/helper-validator-identifier@8.0.4': {} - '@babel/helper-validator-option@7.29.7': {} '@babel/helper-wrap-function@7.29.7': @@ -4213,10 +4096,6 @@ snapshots: dependencies: '@babel/types': 7.29.7 - '@babel/parser@8.0.4': - dependencies: - '@babel/types': 8.0.4 - '@babel/plugin-bugfix-firefox-class-in-computed-class-key@7.29.7(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 @@ -4568,12 +4447,15 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/plugin-transform-modules-systemjs@8.0.1(@babel/core@7.29.7)': + '@babel/plugin-transform-modules-systemjs@7.29.7(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-module-transforms': 8.0.1(@babel/core@7.29.7) - '@babel/helper-plugin-utils': 8.0.1(@babel/core@7.29.7) - '@babel/helper-validator-identifier': 8.0.4 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) + '@babel/helper-plugin-utils': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + '@babel/traverse': 7.29.7 + transitivePeerDependencies: + - supports-color '@babel/plugin-transform-modules-umd@7.29.7(@babel/core@7.29.7)': dependencies: @@ -4839,7 +4721,7 @@ snapshots: '@babel/plugin-transform-member-expression-literals': 7.29.7(@babel/core@7.29.7) '@babel/plugin-transform-modules-amd': 7.29.7(@babel/core@7.29.7) '@babel/plugin-transform-modules-commonjs': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-modules-systemjs': 8.0.1(@babel/core@7.29.7) + '@babel/plugin-transform-modules-systemjs': 7.29.7(@babel/core@7.29.7) '@babel/plugin-transform-modules-umd': 7.29.7(@babel/core@7.29.7) '@babel/plugin-transform-named-capturing-groups-regex': 7.29.7(@babel/core@7.29.7) '@babel/plugin-transform-new-target': 7.29.7(@babel/core@7.29.7) @@ -4912,12 +4794,6 @@ snapshots: '@babel/parser': 7.29.7 '@babel/types': 7.29.7 - '@babel/template@8.0.0': - dependencies: - '@babel/code-frame': 8.0.0 - '@babel/parser': 8.0.4 - '@babel/types': 8.0.4 - '@babel/traverse@7.29.7': dependencies: '@babel/code-frame': 7.29.7 @@ -4930,26 +4806,11 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/traverse@8.0.4': - dependencies: - '@babel/code-frame': 8.0.0 - '@babel/generator': 8.0.0 - '@babel/helper-globals': 8.0.0 - '@babel/parser': 8.0.4 - '@babel/template': 8.0.0 - '@babel/types': 8.0.4 - obug: 2.1.3 - '@babel/types@7.29.7': dependencies: '@babel/helper-string-parser': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 - '@babel/types@8.0.4': - dependencies: - '@babel/helper-string-parser': 8.0.0 - '@babel/helper-validator-identifier': 8.0.4 - '@bcoe/v8-coverage@0.2.3': {} '@emnapi/core@1.10.0': @@ -5376,7 +5237,7 @@ snapshots: camelcase: 5.3.1 find-up: 4.1.0 get-package-type: 0.1.0 - js-yaml: 3.15.0 + js-yaml: 5.2.1 resolve-from: 5.0.0 '@istanbuljs/schema@0.1.6': {} @@ -5765,7 +5626,7 @@ snapshots: nullthrows: 1.1.1 open: 7.4.2 serve-static: 1.16.3 - ws: 7.5.11 + ws: 8.21.0 transitivePeerDependencies: - bufferutil - supports-color @@ -5784,7 +5645,7 @@ snapshots: nullthrows: 1.1.1 open: 7.4.2 serve-static: 1.16.3 - ws: 7.5.11 + ws: 8.21.0 transitivePeerDependencies: - bufferutil - supports-color @@ -5886,8 +5747,6 @@ snapshots: expect: 30.4.1 pretty-format: 30.4.1 - '@types/jsesc@2.5.1': {} - '@types/json-schema@7.0.15': {} '@types/node@26.1.1': @@ -6160,10 +6019,6 @@ snapshots: arg@5.0.2: {} - argparse@1.0.10: - dependencies: - sprintf-js: 1.0.3 - argparse@2.0.1: {} arkregex@0.0.8: @@ -6662,8 +6517,6 @@ snapshots: acorn-jsx: 5.3.2(acorn@8.17.0) eslint-visitor-keys: 5.0.1 - esprima@4.0.1: {} - esquery@1.7.0: dependencies: estraverse: 5.3.0 @@ -7518,16 +7371,13 @@ snapshots: jimp-compact@0.16.1: {} - js-tokens@10.0.0: {} - js-tokens@4.0.0: {} - js-yaml@3.15.0: + js-yaml@4.3.0: dependencies: - argparse: 1.0.10 - esprima: 4.0.1 + argparse: 2.0.1 - js-yaml@4.3.0: + js-yaml@5.2.1: dependencies: argparse: 2.0.1 @@ -7841,7 +7691,7 @@ snapshots: serialize-error: 2.1.0 source-map: 0.5.7 throat: 5.0.0 - ws: 7.5.11 + ws: 8.21.0 yargs: 17.7.3 transitivePeerDependencies: - bufferutil @@ -7934,8 +7784,6 @@ snapshots: dependencies: flow-enums-runtime: 0.0.6 - obug@2.1.3: {} - on-finished@2.3.0: dependencies: ee-first: 1.1.1 @@ -8127,7 +7975,7 @@ snapshots: react-devtools-core@6.1.5: dependencies: shell-quote: 1.10.0 - ws: 7.5.11 + ws: 8.21.0 transitivePeerDependencies: - bufferutil - utf-8-validate @@ -8206,7 +8054,7 @@ snapshots: stacktrace-parser: 0.1.11 tinyglobby: 0.2.17 whatwg-fetch: 3.6.20 - ws: 7.5.11 + ws: 8.21.0 yargs: 17.7.3 optionalDependencies: '@types/react': 19.2.17 @@ -8354,8 +8202,6 @@ snapshots: source-map@0.6.1: {} - sprintf-js@1.0.3: {} - stack-utils@2.0.6: dependencies: escape-string-regexp: 2.0.0 @@ -8670,8 +8516,6 @@ snapshots: imurmurhash: 0.1.4 signal-exit: 4.1.0 - ws@7.5.11: {} - ws@8.21.0: {} xcode@3.0.1: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 607dc77..c64e8c8 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -37,7 +37,7 @@ trustPolicyExclude: # Dependency overrides # Pin transitive deps to patched versions for advisories not yet fixed upstream. overrides: - '@babel/plugin-transform-modules-systemjs': '>=7.29.4' + '@babel/plugin-transform-modules-systemjs': '>=7.29.4 <8' '@react-native-community/cli': '>=17.0.1' '@react-native-community/cli-server-api': '>=17.0.1' '@ungap/structured-clone': '>=1.3.1' @@ -46,6 +46,7 @@ overrides: 'brace-expansion@2': '>=2.0.3 <3' 'brace-expansion@5': '>=5.0.6 <6' 'fast-xml-parser': '>=4.5.5' + 'js-yaml@3': '>=3.15.0' 'minimatch@3': '>=3.1.5 <4' 'minimatch@5': '>=5.1.9 <6' 'minimatch@9': '>=9.0.9 <10' @@ -57,9 +58,27 @@ overrides: 'postcss': '>=8.5.10' 'semver@5': '>=7.7.4' 'semver@6': '>=7.7.4' + 'shell-quote': '>=1.8.4' 'tar': '>=7.5.13' + 'tmp': '>=0.2.6' 'undici': '>=6.24.1 <7' 'uuid': '>=11.0.0' - 'ws@8': '>=8.20.1' + 'ws@6': '>=6.2.4' + 'ws@7': '>=7.5.11' + 'ws@8': '>=8.21.0' 'yaml@1': '>=1.10.3 <2' 'yaml@2': '>=2.8.3 <3' + +# Audit policy: ignore advisories that only affect react-native's / wagmi's BUILD +# tooling (never shipped in the SDK's runtime) and that cannot be cleanly patched. +# The overrides above already bump the fixable instances (shell-quote, tmp, ws@6/7, +# most ws@8, js-yaml@3); these residuals have no non-breaking fix available: +# GHSA-4x5r-pxfx-6jf8 @babel/core (LOW) no patched version published (>=7.29.1 absent) +# GHSA-96hv-2xvq-fx4p ws (HIGH) one build-tooling ws@8.20.1 resists the >=8.21.0 override +# GHSA-h67p-54hq-rp68 js-yaml (MODERATE) the only 4.x fix is a breaking major bump (5.x) +# Revisit as upstream (react-native / metro / viem) updates these transitive deps. +auditConfig: + ignoreGhsas: + - GHSA-4x5r-pxfx-6jf8 + - GHSA-96hv-2xvq-fx4p + - GHSA-h67p-54hq-rp68 diff --git a/src/FormoAnalytics.ts b/src/FormoAnalytics.ts index 9e7d280..84b3a3c 100644 --- a/src/FormoAnalytics.ts +++ b/src/FormoAnalytics.ts @@ -8,6 +8,8 @@ import { EVENTS_API_HOST, EventType, LOCAL_ANONYMOUS_ID_KEY, + LOCAL_SESSION_ID_KEY, + LOCAL_SESSION_LAST_ACTIVITY_KEY, SESSION_USER_ID_KEY, CONSENT_OPT_OUT_KEY, TEventType, @@ -256,6 +258,8 @@ export class FormoAnalytics implements IFormoAnalytics { public reset(): void { this.currentUserId = undefined; storage().remove(LOCAL_ANONYMOUS_ID_KEY); + storage().remove(LOCAL_SESSION_ID_KEY); + storage().remove(LOCAL_SESSION_LAST_ACTIVITY_KEY); storage().remove(SESSION_USER_ID_KEY); this.session.clear(); } diff --git a/src/__tests__/hash.test.ts b/src/__tests__/hash.test.ts index 66930fd..825d7e2 100644 --- a/src/__tests__/hash.test.ts +++ b/src/__tests__/hash.test.ts @@ -79,5 +79,36 @@ describe('hash utilities', () => { const uuid = generateUUID(); expect(uuid).toHaveLength(36); }); + + // The secure path uses Web Crypto (present in jest). These tests force the + // no-Web-Crypto fallback to confirm it still yields valid, unique UUIDs + // without crashing (and without Math.random). + describe('fallback when Web Crypto is unavailable', () => { + const realCrypto = (globalThis as { crypto?: unknown }).crypto; + beforeEach(() => { + Object.defineProperty(globalThis, 'crypto', { + value: undefined, + configurable: true, + }); + }); + afterEach(() => { + Object.defineProperty(globalThis, 'crypto', { + value: realCrypto, + configurable: true, + }); + }); + + it('still produces a valid UUID v4', () => { + expect(generateUUID()).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/ + ); + }); + + it('produces unique ids even within the same millisecond (counter)', () => { + const ids = new Set(); + for (let i = 0; i < 100; i++) ids.add(generateUUID()); + expect(ids.size).toBe(100); + }); + }); }); }); diff --git a/src/__tests__/screenEvent.test.ts b/src/__tests__/screenEvent.test.ts new file mode 100644 index 0000000..ebd96f3 --- /dev/null +++ b/src/__tests__/screenEvent.test.ts @@ -0,0 +1,42 @@ +import { EventFactory } from "../lib/event/EventFactory"; +import { initStorageManager } from "../lib/storage"; + +/** + * Mobile screen views are emitted as type="page" with page_url `app://`. + * The ingestion pipeline (P-2070) owns the interpretation: it derives `origin` + * from the app identifier in context (app_name / app_bundle_id) and `page_path` + * by stripping the app:// scheme. So the SDK emits the screen name as-is and + * must NOT encode an app host in the URL — doing so would leak the host into the + * backend-derived page_path. + */ +describe("generateScreenEvent page_url", () => { + beforeEach(() => initStorageManager("screen-test-key")); + + it("emits the screen name as app://", async () => { + const factory = new EventFactory(); + const evt = await factory.generateScreenEvent("Home"); + expect(evt.context?.page_url).toBe("app://Home"); + expect(evt.context?.page_title).toBe("Home"); + }); + + it("passes router-style paths through unchanged", async () => { + const factory = new EventFactory(); + const evt = await factory.generateScreenEvent("/tabs/leaderboard"); + expect(evt.context?.page_url).toBe("app:///tabs/leaderboard"); + }); + + it("does NOT encode the app bundle id into the URL (backend owns origin)", async () => { + const factory = new EventFactory({ app: { bundleId: "com.formo.test" } }); + const evt = await factory.generateScreenEvent("Wallet"); + expect(evt.context?.page_url).toBe("app://Wallet"); + expect(String(evt.context?.page_url)).not.toContain("com.formo.test"); + }); + + it("keeps user-supplied context (spread last)", async () => { + const factory = new EventFactory(); + const evt = await factory.generateScreenEvent("Home", undefined, undefined, { + page_url: "app://Custom", + }); + expect(evt.context?.page_url).toBe("app://Custom"); + }); +}); diff --git a/src/__tests__/sessionId.test.ts b/src/__tests__/sessionId.test.ts new file mode 100644 index 0000000..62d13af --- /dev/null +++ b/src/__tests__/sessionId.test.ts @@ -0,0 +1,55 @@ +import { getSessionId } from "../lib/event/EventFactory"; +import { initStorageManager, storage } from "../lib/storage"; +import { + LOCAL_SESSION_ID_KEY, + LOCAL_SESSION_LAST_ACTIVITY_KEY, + SESSION_TIMEOUT_MS, +} from "../constants"; + +/** + * These tests exist because a missing/empty session_id collapses every mobile + * user of a project into a single downstream session (bounce/engagement/duration + * all break). They pin the intended contract: one stable id per active session, + * a fresh id after the inactivity timeout. + */ +describe("getSessionId", () => { + beforeEach(() => { + initStorageManager("session-test-key"); + storage().remove(LOCAL_SESSION_ID_KEY); + storage().remove(LOCAL_SESSION_LAST_ACTIVITY_KEY); + }); + + it("mints and persists a UUID session id on first call", () => { + const id = getSessionId(); + expect(id).toMatch(/^[0-9a-f-]{36}$/); + expect(storage().get(LOCAL_SESSION_ID_KEY)).toBe(id); + // Every event must carry an id — the whole point of the fix. + expect(id).not.toBe(""); + }); + + it("reuses the same id for events within the inactivity window", () => { + expect(getSessionId()).toBe(getSessionId()); + }); + + it("mints a new id once the inactivity timeout has elapsed", () => { + const first = getSessionId(); + // Simulate the previous event happening longer ago than the timeout. + storage().set( + LOCAL_SESSION_LAST_ACTIVITY_KEY, + String(Date.now() - (SESSION_TIMEOUT_MS + 1)) + ); + const second = getSessionId(); + expect(second).not.toBe(first); + expect(storage().get(LOCAL_SESSION_ID_KEY)).toBe(second); + }); + + it("refreshes the last-activity marker on every call", () => { + getSessionId(); + const firstMarker = Number(storage().get(LOCAL_SESSION_LAST_ACTIVITY_KEY)); + // Backdate the marker, then confirm the next call moves it forward. + storage().set(LOCAL_SESSION_LAST_ACTIVITY_KEY, String(firstMarker - 1000)); + getSessionId(); + const secondMarker = Number(storage().get(LOCAL_SESSION_LAST_ACTIVITY_KEY)); + expect(secondMarker).toBeGreaterThan(firstMarker - 1000); + }); +}); diff --git a/src/__tests__/userAgent.test.ts b/src/__tests__/userAgent.test.ts new file mode 100644 index 0000000..1b260e0 --- /dev/null +++ b/src/__tests__/userAgent.test.ts @@ -0,0 +1,57 @@ +import { synthesizeUserAgent } from "../lib/event/EventFactory"; + +/** + * The ingestion pipeline (Tinybird dedup_raw_events) classifies device + os + * SOLELY from the lowercased user_agent string. A mobile event with an empty UA + * is bucketed device=os='unknown'. These tests pin the synthesized UA to the + * exact tokens that classifier matches, so mobile device/os resolve correctly: + * device: 'iphone|ipod'->mobile-ios, 'ipad'->tablet, + * 'android'+'mobile'->mobile-android, 'android'+tablet/no-mobile->tablet + * os: 'iphone|ipad|ipod'->ios, 'android[ /]'->android + */ +describe("synthesizeUserAgent", () => { + const ua = (o: Partial[0]>) => + synthesizeUserAgent({ + os_name: "", + os_version: "", + device_model: "", + device_type: "mobile", + ...o, + }).toLowerCase(); + + it("iOS phone → classifies as mobile-ios (has 'iphone', no 'ipad')", () => { + const s = ua({ os_name: "iOS", os_version: "17.4.1", device_type: "mobile" }); + expect(s).toContain("iphone"); + expect(s).not.toContain("ipad"); + // os regex expects 'iphone os _' + expect(/iphone os [\d_]+ like mac os x/.test(s)).toBe(true); + }); + + it("iOS tablet → classifies as tablet (has 'ipad')", () => { + const s = ua({ os_name: "iOS", os_version: "17.4", device_type: "tablet" }); + expect(s).toContain("ipad"); + }); + + it("Android phone → mobile-android (has 'android' + 'mobile', versioned)", () => { + const s = ua({ os_name: "Android", os_version: "14", device_model: "Pixel 8", device_type: "mobile" }); + expect(s).toContain("android"); + expect(s).toContain("mobile"); + expect(/android[/ ][\d.]+/.test(s)).toBe(true); + }); + + it("Android tablet → tablet (has 'android' + tablet token, NOT 'mobile')", () => { + const s = ua({ os_name: "Android", os_version: "13", device_model: "Tab S9", device_type: "tablet" }); + expect(s).toContain("android"); + expect(s).toContain("tablet"); + expect(s).not.toContain("mobile"); + }); + + it("handles Expo's capitalized osName and lowercase Platform.OS alike", () => { + expect(ua({ os_name: "ios" })).toContain("iphone"); + expect(ua({ os_name: "iOS" })).toContain("iphone"); + }); + + it("returns empty string for unknown platforms (no false classification)", () => { + expect(synthesizeUserAgent({ os_name: "windows", os_version: "11", device_model: "PC", device_type: "mobile" })).toBe(""); + }); +}); diff --git a/src/constants/events.ts b/src/constants/events.ts index fdcccc9..b415262 100644 --- a/src/constants/events.ts +++ b/src/constants/events.ts @@ -24,3 +24,7 @@ export type TEventChannel = Lowercase; // React Native SDK uses mobile channel export const CHANNEL: TEventChannel = "mobile"; export const VERSION = "0"; + +// Session inactivity timeout (30 min), matching the GA4/Segment default. A new +// session_id is minted once the gap since the last tracked event exceeds this. +export const SESSION_TIMEOUT_MS = 30 * 60 * 1000; diff --git a/src/constants/storage.ts b/src/constants/storage.ts index ddea88e..5505ad0 100644 --- a/src/constants/storage.ts +++ b/src/constants/storage.ts @@ -5,6 +5,10 @@ export const STORAGE_PREFIX = "formo_rn_"; export const LOCAL_ANONYMOUS_ID_KEY = "anonymous_id"; export const LOCAL_APP_VERSION_KEY = "app_version"; export const LOCAL_APP_BUILD_KEY = "app_build"; +// Session identifier + last-activity marker. Persisted so a session survives an +// app restart, but expires after SESSION_TIMEOUT_MS of inactivity (see EventFactory). +export const LOCAL_SESSION_ID_KEY = "session_id"; +export const LOCAL_SESSION_LAST_ACTIVITY_KEY = "session_last_activity"; // One-shot flag: set once the Install Referrer (Android) or AdServices (iOS) // attribution has been fetched, so we never call the native API again. export const LOCAL_INSTALL_REFERRER_RESOLVED_KEY = "install_referrer_resolved"; diff --git a/src/lib/event/EventFactory.ts b/src/lib/event/EventFactory.ts index 142c4a2..0da827e 100644 --- a/src/lib/event/EventFactory.ts +++ b/src/lib/event/EventFactory.ts @@ -25,7 +25,15 @@ try { } catch { // Not available } -import { COUNTRY_LIST, LOCAL_ANONYMOUS_ID_KEY, CHANNEL, VERSION } from "../../constants"; +import { + COUNTRY_LIST, + LOCAL_ANONYMOUS_ID_KEY, + LOCAL_SESSION_ID_KEY, + LOCAL_SESSION_LAST_ACTIVITY_KEY, + SESSION_TIMEOUT_MS, + CHANNEL, + VERSION, +} from "../../constants"; import { Address, APIEvent, @@ -65,6 +73,73 @@ function generateAnonymousId(key: string): string { return newId; } +/** + * Get the current session id, or start a new one. + * + * A session persists across app restarts but expires after SESSION_TIMEOUT_MS of + * inactivity, at which point a fresh id is minted. Every call refreshes the + * last-activity marker. + * + * The mobile SDK owns its session_id rather than letting ingestion derive one. + * The events-gateway authorizer computes + * `hash(dailySalt + domain + sourceIp + userAgent)` — a design built for the + * web, where all three inputs carry real entropy. For a native app they all + * degenerate at once: there is no Origin header (so `domain` is the constant + * "unknown"), the HTTP User-Agent is the client library's (`okhttp/…`, + * `CFNetwork/… Darwin/…`) and is near-identical across users on the same app + * build, and carrier CGNAT puts many users behind one IP — so unrelated users + * collapse into a single session. Ingestion honours a body-provided session_id + * (`obj?.session_id || session_id` in handlerV0), which is the path used here. + */ +export function getSessionId(): string { + const now = Date.now(); + const existingId = storage().get(LOCAL_SESSION_ID_KEY); + const lastActivityRaw = storage().get(LOCAL_SESSION_LAST_ACTIVITY_KEY); + const lastActivity = lastActivityRaw ? parseInt(lastActivityRaw, 10) : 0; + + const isExpired = + !existingId || !lastActivity || now - lastActivity > SESSION_TIMEOUT_MS; + const sessionId = isExpired ? generateUUID() : existingId; + + storage().set(LOCAL_SESSION_ID_KEY, sessionId); + storage().set(LOCAL_SESSION_LAST_ACTIVITY_KEY, String(now)); + + return sessionId; +} + +/** + * Build a representative User-Agent string from structured device info. + * + * The ingestion pipeline classifies device / os / browser purely from the + * user_agent string; a mobile event with an empty UA is bucketed as "unknown". + * Platforms without a native UA (e.g. Expo, where DeviceInfo.getUserAgent() is + * unavailable) would otherwise report device=os=unknown. We synthesize a UA + * containing the tokens the classifier keys off (iphone/ipad/android + version) + * so mobile device and OS resolve correctly. Returns "" for unknown platforms. + */ +export function synthesizeUserAgent(info: { + os_name: string; + os_version: string; + device_model: string; + device_type: string; +}): string { + const os = (info.os_name || "").toLowerCase(); + const version = info.os_version || ""; + const model = info.device_model || ""; + const isTablet = info.device_type === "tablet"; + + if (os === "ios" || os === "ipados") { + const device = isTablet ? "iPad" : "iPhone"; + const osVersion = version.replace(/\./g, "_"); + return `Mozilla/5.0 (${device}; CPU ${device} OS ${osVersion} like Mac OS X) FormoAnalytics/ReactNative`; + } + if (os === "android") { + const formFactor = isTablet ? "Tablet" : "Mobile"; + return `Mozilla/5.0 (Linux; Android ${version}; ${model}) ${formFactor} FormoAnalytics/ReactNative`; + } + return ""; +} + /** * Event factory for React Native * Creates event payloads with mobile-specific context @@ -218,14 +293,24 @@ class EventFactory implements IEventFactory { DeviceInfo.isTablet(), ]); + const device_type = isTablet ? "tablet" : "mobile"; + const os_version = DeviceInfo.getSystemVersion(); return { os_name: Platform.OS, - os_version: DeviceInfo.getSystemVersion(), + os_version, device_model: model, device_manufacturer: manufacturer, device_name: deviceName, - device_type: isTablet ? "tablet" : "mobile", - user_agent: userAgent, + device_type, + // Prefer the native UA; fall back to a synthesized one if unavailable. + user_agent: + userAgent || + synthesizeUserAgent({ + os_name: Platform.OS, + os_version, + device_model: model, + device_type, + }), app_name: DeviceInfo.getApplicationName(), app_version: DeviceInfo.getVersion(), app_build: DeviceInfo.getBuildNumber(), @@ -240,14 +325,25 @@ class EventFactory implements IEventFactory { if (ExpoDevice || ExpoApplication) { try { const isTablet = ExpoDevice?.deviceType === ExpoDevice?.DeviceType?.TABLET; + const os_name = ExpoDevice?.osName || Platform.OS; + const os_version = ExpoDevice?.osVersion || String(Platform.Version); + const device_model = ExpoDevice?.modelName || "Unknown"; + const device_type = isTablet ? "tablet" : "mobile"; return { - os_name: ExpoDevice?.osName || Platform.OS, - os_version: ExpoDevice?.osVersion || String(Platform.Version), - device_model: ExpoDevice?.modelName || "Unknown", + os_name, + os_version, + device_model, device_manufacturer: ExpoDevice?.manufacturer || "Unknown", device_name: ExpoDevice?.deviceName || "Unknown Device", - device_type: isTablet ? "tablet" : "mobile", - user_agent: "", + device_type, + // Expo exposes no native UA; synthesize one so the pipeline can + // classify device/os (both are derived from the UA string). + user_agent: synthesizeUserAgent({ + os_name, + os_version, + device_model, + device_type, + }), app_name: ExpoApplication?.applicationName || "", app_version: ExpoApplication?.nativeApplicationVersion || "", app_build: ExpoApplication?.nativeBuildVersion || "", @@ -260,14 +356,21 @@ class EventFactory implements IEventFactory { // Final fallback - minimal info from Platform logger.debug("No device info modules available, using Platform defaults"); + const os_name = Platform.OS; + const os_version = String(Platform.Version); return { - os_name: Platform.OS, - os_version: String(Platform.Version), + os_name, + os_version, device_model: "Unknown", device_manufacturer: "Unknown", device_name: "Unknown Device", device_type: "mobile", - user_agent: "", + user_agent: synthesizeUserAgent({ + os_name, + os_version, + device_model: "Unknown", + device_type: "mobile", + }), app_name: "", app_version: "", app_build: "", @@ -334,6 +437,7 @@ class EventFactory implements IEventFactory { }; commonEventData.anonymous_id = generateAnonymousId(LOCAL_ANONYMOUS_ID_KEY); + commonEventData.session_id = getSessionId(); // Handle address - convert undefined to null for consistency // Try EVM first, then Solana fallback (chainId is not always present here). @@ -383,8 +487,12 @@ class EventFactory implements IEventFactory { ): Promise { const props = { ...(properties ?? {}), name, ...(category && { category }) }; - // Map screen name to page-equivalent context fields for Tinybird compatibility. - // page_path is omitted — Tinybird derives it from page_url via path(). + // Map screen name to page-equivalent context fields so mobile screens flow + // through the same analytics as web page views. The screen name is emitted + // as-is in the app:// URL; the ingestion pipeline derives `origin` (from the + // app identifier in context — app_name / app_bundle_id) and `page_path` (by + // stripping the app:// scheme), so the SDK deliberately does NOT encode a + // host here (see backend mobile page-event handling, P-2070). // User-supplied context values take precedence (spread last). const screenContext: IFormoEventContext = { page_title: name, diff --git a/src/lib/event/EventQueue.ts b/src/lib/event/EventQueue.ts index 7dad374..f155b4d 100644 --- a/src/lib/event/EventQueue.ts +++ b/src/lib/event/EventQueue.ts @@ -168,6 +168,20 @@ export class EventQueue implements IEventQueue { `Event enqueued: ${getActionDescriptor(event.type, event.properties)}` ); + // Per-event detail line for debugging (only prints when debug logging is on). + const ctx = (event.context ?? {}) as Record; + logger.debug( + "Event detail:", + JSON.stringify({ + type: event.type, + event: event.event, + session_id: event.session_id, + anonymous_id: event.anonymous_id, + user_agent: ctx.user_agent, + page_url: ctx.page_url, + }) + ); + const hasReachedFlushAt = this.queue.length >= this.flushAt; const hasReachedQueueSize = this.queue.reduce( diff --git a/src/types/events.ts b/src/types/events.ts index c11ed1f..0813c34 100644 --- a/src/types/events.ts +++ b/src/types/events.ts @@ -5,6 +5,7 @@ export type AnonymousID = string; export interface ICommonProperties { anonymous_id: AnonymousID; + session_id: string; user_id: Nullable; address: Nullable; type: TEventType; diff --git a/src/utils/hash.ts b/src/utils/hash.ts index 5d807f5..8bd9946 100644 --- a/src/utils/hash.ts +++ b/src/utils/hash.ts @@ -11,13 +11,58 @@ export async function hash(input: string): Promise { return bytesToHex(hashBytes); } +// Monotonic counter for the no-Web-Crypto fallback below. Guarantees the +// fallback produces distinct ids even for calls within the same millisecond. +let uuidFallbackCounter = 0; + +/** Format 16 bytes as a UUID v4 string (sets the version + variant bits). */ +function formatUuidV4(source: Uint8Array): string { + const bytes = source.slice(0, 16); + bytes[6] = ((bytes[6] ?? 0) & 0x0f) | 0x40; // version 4 + bytes[8] = ((bytes[8] ?? 0) & 0x3f) | 0x80; // variant 10xx + const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join(""); + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`; +} + /** - * Generate a UUID v4 + * Generate a UUID v4. + * + * Uses a cryptographically secure RNG (Web Crypto), which is present in React + * Native whenever the app polyfills it via `react-native-get-random-values` — + * wallet apps using wagmi/viem already do, since those require secure randomness. + * On a runtime with no Web Crypto at all, derives a unique id from a monotonic + * counter + timestamp via SHA-256 (no PRNG) so the SDK never throws; that path + * is not cryptographically random, but it is only ever reached without Web + * Crypto, and these IDs are analytics identifiers, not security tokens. */ export function generateUUID(): string { - return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => { - const r = (Math.random() * 16) | 0; - const v = c === "x" ? r : (r & 0x3) | 0x8; - return v.toString(16); - }); + const webCrypto: { + randomUUID?: () => string; + getRandomValues?: (a: Uint8Array) => Uint8Array; + } | undefined = (globalThis as { crypto?: unknown }).crypto as + | { randomUUID?: () => string; getRandomValues?: (a: Uint8Array) => Uint8Array } + | undefined; + + // Fastest secure path: native randomUUID. + if (typeof webCrypto?.randomUUID === "function") { + return webCrypto.randomUUID(); + } + + // Secure random bytes formatted as a UUID v4. + if (typeof webCrypto?.getRandomValues === "function") { + return formatUuidV4(webCrypto.getRandomValues(new Uint8Array(16))); + } + + // No Web Crypto available: derive a UUID from a monotonic counter + timestamp + // + Math.random, hashed with SHA-256. Math.random is not cryptographically + // secure — CodeQL flags it, and that alert is intentionally dismissed: these + // are analytics identifiers, not security tokens, and this branch is only + // reached on runtimes without a Web Crypto polyfill. The Math.random term + // restores the cross-process entropy needed so two fresh app processes that + // make their first call within the same millisecond do not collide (the + // counter alone only prevents collisions within a single process). + uuidFallbackCounter = (uuidFallbackCounter + 1) >>> 0; + return formatUuidV4( + sha256(utf8ToBytes(`${Date.now()}-${uuidFallbackCounter}-${Math.random()}`)) + ); }