diff --git a/package-lock.json b/package-lock.json index 5761ec7..d45d79d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "3.6.0", "license": "ISC", "dependencies": { + "@segment/analytics-next": "^1.84.0", "@tkey/common-types": "^15.1.0", "@tkey/core": "^15.1.0", "@tkey/share-serialization": "^15.1.0", @@ -41,7 +42,7 @@ "@types/chai": "^4.3.16", "@types/elliptic": "^6.4.18", "@types/jsonwebtoken": "^9.0.7", - "@types/node": "^20.14.0", + "@types/node": "^22.20.2", "@typescript-eslint/parser": "^7.18.0", "chai": "^5.1.1", "cross-env": "^7.0.3", @@ -108,6 +109,7 @@ "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", @@ -1699,6 +1701,7 @@ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=6.9.0" } @@ -1804,7 +1807,6 @@ "os": [ "aix" ], - "peer": true, "engines": { "node": ">=18" } @@ -1822,7 +1824,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">=18" } @@ -1840,7 +1841,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">=18" } @@ -1858,7 +1858,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">=18" } @@ -1876,7 +1875,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">=18" } @@ -1894,7 +1892,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">=18" } @@ -1912,7 +1909,6 @@ "os": [ "freebsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -1930,7 +1926,6 @@ "os": [ "freebsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -1948,7 +1943,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -1966,7 +1960,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -1984,7 +1977,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -2002,7 +1994,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -2020,7 +2011,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -2038,7 +2028,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -2056,7 +2045,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -2074,7 +2062,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -2092,7 +2079,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -2110,7 +2096,6 @@ "os": [ "netbsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -2128,7 +2113,6 @@ "os": [ "netbsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -2146,7 +2130,6 @@ "os": [ "openbsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -2164,7 +2147,6 @@ "os": [ "openbsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -2182,7 +2164,6 @@ "os": [ "openharmony" ], - "peer": true, "engines": { "node": ">=18" } @@ -2200,7 +2181,6 @@ "os": [ "sunos" ], - "peer": true, "engines": { "node": ">=18" } @@ -2218,7 +2198,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">=18" } @@ -2236,7 +2215,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">=18" } @@ -2254,7 +2232,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">=18" } @@ -2576,6 +2553,27 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@lukeed/csprng": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@lukeed/csprng/-/csprng-1.1.0.tgz", + "integrity": "sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@lukeed/uuid": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@lukeed/uuid/-/uuid-2.0.1.tgz", + "integrity": "sha512-qC72D4+CDdjGqJvkFMMEAtancHUQ7/d/tAiHf64z8MopFDmcrtbcJuerDtFceuAfQJ2pDSfCKCtbqoGBNnwg0w==", + "license": "MIT", + "dependencies": { + "@lukeed/csprng": "^1.1.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/@microsoft/tsdoc": { "version": "0.15.0", "resolved": "https://registry.npmjs.org/@microsoft/tsdoc/-/tsdoc-0.15.0.tgz", @@ -2720,6 +2718,7 @@ "integrity": "sha512-/g2d4sW9nUDJOMz3mabVQvOGhVa4e/BN/Um7yca9Bb2XTzPPnfTWHWQg+IsEYO7M3Vx+EXvaM/I2pJWIMun1bg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@octokit/auth-token": "^4.0.0", "@octokit/graphql": "^7.1.0", @@ -3407,6 +3406,117 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/@segment/analytics-core": { + "version": "1.8.3", + "resolved": "https://registry.npmjs.org/@segment/analytics-core/-/analytics-core-1.8.3.tgz", + "integrity": "sha512-63X8e2DWb2ZnJ9S3H8iSOFnbeDXMkTGhA8kZG4eFAO07QBwBVyWo5BCpv6vmuOMEkv2le6t2FMg1p6ZeJQA/4A==", + "license": "MIT", + "dependencies": { + "@lukeed/uuid": "^2.0.0", + "@segment/analytics-generic-utils": "1.2.0", + "dset": "^3.1.4", + "tslib": "^2.4.1" + } + }, + "node_modules/@segment/analytics-generic-utils": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@segment/analytics-generic-utils/-/analytics-generic-utils-1.2.0.tgz", + "integrity": "sha512-DfnW6mW3YQOLlDQQdR89k4EqfHb0g/3XvBXkovH1FstUN93eL1kfW9CsDcVQyH3bAC5ZsFyjA/o/1Q2j0QeoWw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.4.1" + } + }, + "node_modules/@segment/analytics-next": { + "version": "1.84.1", + "resolved": "https://registry.npmjs.org/@segment/analytics-next/-/analytics-next-1.84.1.tgz", + "integrity": "sha512-jcwALgEc4DP6PG6oj0YS0yLiQL2R/N1NAgGGH50898/FNcXLyv/v8c+qoiHJxIpGm3qp8nJZTDeGe1z1Co2NzA==", + "license": "MIT", + "dependencies": { + "@lukeed/uuid": "^2.0.0", + "@segment/analytics-core": "1.8.3", + "@segment/analytics-generic-utils": "1.2.0", + "@segment/analytics-page-tools": "1.0.0", + "@segment/analytics.js-video-plugins": "^0.2.1", + "@segment/facade": "^3.4.9", + "dset": "^3.1.4", + "js-cookie": "^3.0.7", + "node-fetch": "^2.6.7", + "tslib": "^2.4.1", + "unfetch": "^4.1.0" + } + }, + "node_modules/@segment/analytics-next/node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/@segment/analytics-page-tools": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@segment/analytics-page-tools/-/analytics-page-tools-1.0.0.tgz", + "integrity": "sha512-o9OVB91qLB9qb0Bw1HfjmWm5AnrMNULRjx++4lBqLt8InKRX1urrRBparVlpj+yJA0sckN5ZcsfazRLuPgBYDQ==", + "license": "MIT", + "dependencies": { + "tslib": "^2.4.1" + } + }, + "node_modules/@segment/analytics.js-video-plugins": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@segment/analytics.js-video-plugins/-/analytics.js-video-plugins-0.2.1.tgz", + "integrity": "sha512-lZwCyEXT4aaHBLNK433okEKdxGAuyrVmop4BpQqQSJuRz0DglPZgd9B/XjiiWs1UyOankg2aNYMN3VcS8t4eSQ==", + "license": "ISC", + "dependencies": { + "unfetch": "^3.1.1" + } + }, + "node_modules/@segment/analytics.js-video-plugins/node_modules/unfetch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/unfetch/-/unfetch-3.1.2.tgz", + "integrity": "sha512-L0qrK7ZeAudGiKYw6nzFjnJ2D5WHblUBwmHIqtPS6oKUd+Hcpk7/hKsSmcHsTlpd1TbTNsiRBUKRq3bHLNIqIw==", + "license": "MIT" + }, + "node_modules/@segment/facade": { + "version": "3.4.10", + "resolved": "https://registry.npmjs.org/@segment/facade/-/facade-3.4.10.tgz", + "integrity": "sha512-xVQBbB/lNvk/u8+ey0kC/+g8pT3l0gCT8O2y9Z+StMMn3KAFAQ9w8xfgef67tJybktOKKU7pQGRPolRM1i1pdA==", + "license": "SEE LICENSE IN LICENSE", + "dependencies": { + "@segment/isodate-traverse": "^1.1.1", + "inherits": "^2.0.4", + "new-date": "^1.0.3", + "obj-case": "0.2.1" + } + }, + "node_modules/@segment/isodate": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@segment/isodate/-/isodate-1.0.3.tgz", + "integrity": "sha512-BtanDuvJqnACFkeeYje7pWULVv8RgZaqKHWwGFnL/g/TH/CcZjkIVTfGDp/MAxmilYHUkrX70SqwnYSTNEaN7A==", + "license": "SEE LICENSE IN LICENSE" + }, + "node_modules/@segment/isodate-traverse": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@segment/isodate-traverse/-/isodate-traverse-1.1.1.tgz", + "integrity": "sha512-+G6e1SgAUkcq0EDMi+SRLfT48TNlLPF3QnSgFGVs0V9F3o3fq/woQ2rHFlW20W0yy5NnCUH0QGU3Am2rZy/E3w==", + "license": "SEE LICENSE IN LICENSE", + "dependencies": { + "@segment/isodate": "^1.0.3" + } + }, "node_modules/@sinclair/typebox": { "version": "0.27.8", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", @@ -3734,6 +3844,7 @@ "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.18.0.tgz", "integrity": "sha512-94EQTWZ40mzBc42ATNIBimBEDltSJ9RQHCC8vc/PDbxi4k8dVwUAv4o98dk50M1zB+JGFxp43FP7f8+FP8R6Sw==", "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/regexpp": "^4.10.0", "@typescript-eslint/scope-manager": "7.18.0", @@ -4261,6 +4372,7 @@ "integrity": "sha512-03ruubjWyOHlmljCVoxSuNDdmfZDzsrrz0P2LeJsOXr+ZwFQ+0yQIwNCwt/GYhV7Z31fgtXJTAEs+FYlEL851g==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "@types/estree": "*", "@types/json-schema": "*" @@ -4324,12 +4436,13 @@ } }, "node_modules/@types/node": { - "version": "20.16.3", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.16.3.tgz", - "integrity": "sha512-/wdGiWRkMOm53gAsSyFMXFZHbVg7C6CbkrzHNpaHoYfsUWPg7m6ZRKtvQjgvQ9i8WT540a3ydRlRQbxjY30XxQ==", + "version": "22.20.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.2.tgz", + "integrity": "sha512-xlvWf4Vs9n1PEVYwP1n4vvG07M6y8WgvJ2t0vbrWTmijsIHp1cS+uJ2kMIRdY3nHZK0nCYKrPeD171+SzF4/zw==", "license": "MIT", + "peer": true, "dependencies": { - "undici-types": "~6.19.2" + "undici-types": "~6.21.0" } }, "node_modules/@types/resolve": { @@ -4361,6 +4474,7 @@ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.18.0.tgz", "integrity": "sha512-4Z+L8I2OqhZV8qA132M4wNL30ypZGYOQVBfMgxDH/K5UX0PNqTu1c6za9ST5r9+tavvHiTWmBnKzpCJ/GlVFtg==", "license": "BSD-2-Clause", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "7.18.0", "@typescript-eslint/types": "7.18.0", @@ -4704,6 +4818,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4748,6 +4863,7 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -5676,6 +5792,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.11.12", "caniuse-lite": "^1.0.30001809", @@ -6755,6 +6872,15 @@ "url": "https://dotenvx.com" } }, + "node_modules/dset": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/dset/-/dset-3.1.4.tgz", + "integrity": "sha512-2QF/g9/zTaPDc3BjNcVTGoBbXBgYfMTTceLaYcFJ/W9kggFUkhxD/hMEeuLKbugyef9SqAx8cpgwlIP/jinUTA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -7201,6 +7327,7 @@ "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.0.tgz", "integrity": "sha512-dZ6+mexnaTIbSBZWgou51U6OmzIhYM2VcNdtiTtI7qPNZm35Akpr0f6vtw3w1Kmn5PYo+tZVfh13WrhpS6oLqQ==", "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -7302,6 +7429,7 @@ "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-9.1.0.tgz", "integrity": "sha512-NSWl5BFQWEPi1j4TjVNItzYV7dZXZ+wP6I6ZhrBGpChQhZRUaElihE9uRRkcbRnNb76UMKDF3r+WTmNcGPKsqw==", "license": "MIT", + "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -7390,6 +7518,7 @@ "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz", "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "license": "MIT", + "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -10137,6 +10266,12 @@ "integrity": "sha512-8wb9Yw966OSxApiCt0K3yNJL8pnNeIv+OEq2YMidz4FKP6nonSRoOXc80iXY4JaN2FC11B9qsNmDsm+ZOfMROA==", "license": "MIT" }, + "node_modules/js-cookie": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.8.tgz", + "integrity": "sha512-yeJd4aNAdYZQjaon2bpD/Gb0B/omw7HQOsynXXcOiWVCacbBcPlgn8S/d1X6blFSaHao7ozqtW7NZW19xpCtIw==", + "license": "MIT" + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -11224,6 +11359,15 @@ "node": ">= 0.4.0" } }, + "node_modules/new-date": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/new-date/-/new-date-1.0.3.tgz", + "integrity": "sha512-0fsVvQPbo2I18DT2zVHpezmeeNYV2JaJSrseiHLc17GNOxJzUdx5mvSigPu8LtIfZSij5i1wXnXFspEs2CD6hA==", + "license": "SEE LICENSE IN LICENSE", + "dependencies": { + "@segment/isodate": "1.0.3" + } + }, "node_modules/new-github-release-url": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/new-github-release-url/-/new-github-release-url-2.0.0.tgz", @@ -11335,6 +11479,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/obj-case": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/obj-case/-/obj-case-0.2.1.tgz", + "integrity": "sha512-PquYBBTy+Y6Ob/O2574XHhDtHJlV1cJHMCgW+rDRc9J5hhmRelJB3k5dTK/3cVmFVtzvAKuENeuLpoyTzMzkOg==", + "license": "MIT" + }, "node_modules/object-inspect": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", @@ -12152,6 +12302,7 @@ "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.3.3.tgz", "integrity": "sha512-i2tDNA0O5IrMO757lfrdQZCc2jPNDVntV0m/+4whiDfWaTKfMNgR7Qz0NAeGz/nRqF4m5/6CLzbP4/liHt12Ew==", "license": "MIT", + "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -13009,6 +13160,7 @@ "integrity": "sha512-RXOqwaPsBGjMNMa4sQjDjHieHEZDFoj/Rdr46l2MU5DfEs16wHJPC2RPTPHWhNl+M3aI472LLqFkFKut4SblOg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/estree": "1.0.9" }, @@ -13224,6 +13376,7 @@ "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -14056,6 +14209,12 @@ "node": ">=6" } }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, "node_modules/ts-api-utils": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.3.0.tgz", @@ -14338,6 +14497,7 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz", "integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==", "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -14375,9 +14535,15 @@ } }, "node_modules/undici-types": { - "version": "6.19.8", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", - "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "license": "MIT" + }, + "node_modules/unfetch": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/unfetch/-/unfetch-4.2.0.tgz", + "integrity": "sha512-F9p7yYCn6cIW9El1zi0HI6vqpeIvBsr3dSuRO6Xuppb1u5rXpCPmMvLSyECLhybr9isec8Ohl0hPekMVrEinDA==", "license": "MIT" }, "node_modules/unicode-canonical-property-names-ecmascript": { @@ -14633,12 +14799,19 @@ "node": ">= 8" } }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, "node_modules/webpack": { "version": "5.109.2", "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.109.2.tgz", "integrity": "sha512-U9/cvLzxObKNEZ9+TtdqrHM5/9z3lgl2c+c4BzbqGxFQvQvBAq87yql5A8pQ+rrMbS496MZJeF5enVBndIy2hw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/estree": "^1.0.8", "@types/json-schema": "^7.0.15", @@ -14755,6 +14928,7 @@ "integrity": "sha512-pIDJHIEI9LR0yxHXQ+Qh95k2EvXpWzZ5l+d+jIo+RdSm9MiHfzazIxwwni/p7+x4eJZuvG1AJwgC4TNQ7NRgsg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@discoveryjs/json-ext": "^0.5.0", "@webpack-cli/configtest": "^2.1.1", @@ -14897,6 +15071,16 @@ "node": ">= 0.6" } }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/when-exit": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/when-exit/-/when-exit-2.1.4.tgz", diff --git a/package.json b/package.json index 684b32d..a8e3dca 100644 --- a/package.json +++ b/package.json @@ -18,8 +18,11 @@ "homepage": "https://github.com/Web3Auth/mpc-core-kit/tree/master#readme", "license": "ISC", "scripts": { + "sync:sdk-version": "node scripts/sync-sdk-version.mjs", + "pretest": "npm run sync:sdk-version", "test": "node --test -r esbuild-register tests/*.spec.ts", "dev": "torus-scripts start", + "prebuild": "npm run sync:sdk-version", "build": "torus-scripts build", "release": "torus-scripts release", "lint": "eslint --fix 'src/**/*.ts'", @@ -43,6 +46,7 @@ } }, "dependencies": { + "@segment/analytics-next": "^1.84.0", "@tkey/common-types": "^15.1.0", "@tkey/core": "^15.1.0", "@tkey/share-serialization": "^15.1.0", @@ -75,7 +79,7 @@ "@types/chai": "^4.3.16", "@types/elliptic": "^6.4.18", "@types/jsonwebtoken": "^9.0.7", - "@types/node": "^20.14.0", + "@types/node": "^22.20.2", "@typescript-eslint/parser": "^7.18.0", "chai": "^5.1.1", "cross-env": "^7.0.3", diff --git a/scripts/sync-sdk-version.mjs b/scripts/sync-sdk-version.mjs new file mode 100644 index 0000000..aeedc02 --- /dev/null +++ b/scripts/sync-sdk-version.mjs @@ -0,0 +1,12 @@ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const scriptDir = path.dirname(fileURLToPath(import.meta.url)); +const { version } = JSON.parse(fs.readFileSync(path.join(scriptDir, "../package.json"), "utf8")); + +const contents = `// Generated from package.json by scripts/sync-sdk-version.mjs. Do not edit. +export const ANALYTICS_SDK_VERSION = ${JSON.stringify(version)}; +`; + +fs.writeFileSync(path.join(scriptDir, "../src/sdkVersion.ts"), contents); diff --git a/src/analytics.ts b/src/analytics.ts new file mode 100644 index 0000000..d80d513 --- /dev/null +++ b/src/analytics.ts @@ -0,0 +1,227 @@ +import type { AnalyticsBrowser, EventProperties, UserTraits } from "@segment/analytics-next"; + +import { log } from "./utils"; + +// Public Segment *source* write key (write-only). This is the same key used by +// web3auth-web. It is not a secret: browser SDKs must ship it in the bundle. +// Mixpanel isolation is via sdk_name / web3auth_client_id, not a private key. +const SEGMENT_WRITE_KEY = "f6LbNqCeVRf512ggdME4b6CyflhF1tsX"; + +export const ANALYTICS_EVENTS = { + SDK_INITIALIZATION_COMPLETED: "SDK Initialization Completed", + SDK_INITIALIZATION_FAILED: "SDK Initialization Failed", + CONNECTION_STARTED: "Connection Started", + CONNECTION_COMPLETED: "Connection Completed", + CONNECTION_FAILED: "Connection Failed", + LOGIN_REQUIRED_SHARE: "Login Required Share", + INPUT_FACTOR_STARTED: "Input Factor Started", + INPUT_FACTOR_COMPLETED: "Input Factor Completed", + INPUT_FACTOR_FAILED: "Input Factor Failed", + MFA_ENABLEMENT_STARTED: "MFA Enablement Started", + MFA_ENABLEMENT_COMPLETED: "MFA Enablement Completed", + MFA_ENABLEMENT_FAILED: "MFA Enablement Failed", + SESSION_REHYDRATION_COMPLETED: "Session Rehydration Completed", + SESSION_REHYDRATION_FAILED: "Session Rehydration Failed", + SESSION_CREATION_FAILED: "Session Creation Failed", + LOGOUT_COMPLETED: "Logout Completed", + LOGOUT_FAILED: "Logout Failed", + FACTOR_CREATION_COMPLETED: "Factor Creation Completed", + FACTOR_CREATION_FAILED: "Factor Creation Failed", + FACTOR_DELETION_COMPLETED: "Factor Deletion Completed", + FACTOR_DELETION_FAILED: "Factor Deletion Failed", +} as const; + +export const ANALYTICS_SDK_NAME = "MPC Core Kit"; +export const ANALYTICS_INTEGRATION_TYPE = "Native SDK"; +export { ANALYTICS_SDK_VERSION } from "./sdkVersion"; + +export type InputFactorFailureReason = "invalid_factor" | "infra_error"; + +export type AnalyticsClient = Pick; +export type AnalyticsClientFactory = () => Promise; + +export interface AnalyticsOptions { + disabled?: boolean; + clientFactory?: AnalyticsClientFactory; +} + +function unwrapAnalyticsClient(client: AnalyticsClient): AnalyticsClient { + // AnalyticsBrowser is PromiseLike<[Analytics, Context]>. An async factory + // that returns it resolves to that tuple instead of the client. Returning a + // plain object also prevents later `await client` from unwrapping it again. + const value = client as AnalyticsClient | [AnalyticsClient, unknown]; + const resolved = Array.isArray(value) && value[0] && typeof value[0].track === "function" ? value[0] : client; + return { + identify: resolved.identify.bind(resolved), + track: resolved.track.bind(resolved), + }; +} + +export class Analytics { + private client?: AnalyticsClient; + + private initializationPromise?: Promise; + + private globalProperties: Record = {}; + + private readonly disabled: boolean; + + private readonly clientFactory: AnalyticsClientFactory; + + public constructor(options: AnalyticsOptions) { + this.disabled = Boolean(options.disabled); + this.clientFactory = + options.clientFactory || + (async () => { + const { AnalyticsBrowser } = await import("@segment/analytics-next"); + const segment = new AnalyticsBrowser(); + await segment.load( + { writeKey: SEGMENT_WRITE_KEY }, + { + user: { + cookie: { key: "web3auth_ajs_user_id" }, + localStorage: { key: "web3auth_ajs_user_traits" }, + }, + globalAnalyticsKey: "web3auth_analytics", + } + ); + // AnalyticsBrowser is a PromiseLike<[Analytics, Context]>, so returning it + // directly from an async function would resolve to that tuple instead of the client. + return { + identify: segment.identify.bind(segment), + track: segment.track.bind(segment), + }; + }); + } + + public init(): void { + if (this.isSkipped() || this.initializationPromise) return; + + this.initializationPromise = this.clientFactory() + .then((client) => { + this.client = unwrapAnalyticsClient(client); + return this.client; + }) + .catch((error: unknown): AnalyticsClient | undefined => { + log.error("Failed to initialize analytics", error); + return undefined; + }); + } + + public setGlobalProperties(properties: Record): void { + this.globalProperties = { ...this.globalProperties, ...properties }; + } + + public async identify(userId: string, traits?: UserTraits): Promise { + if (this.isSkipped()) return; + try { + const client = await this.getClient(); + await client?.identify(userId, traits); + } catch (error) { + log.error(`Failed to identify client ${userId} in analytics`, error); + } + } + + public async track(event: string, properties?: EventProperties): Promise { + if (this.isSkipped()) return; + try { + const client = await this.getClient(); + await client?.track(event, { ...this.globalProperties, ...properties }); + } catch (error) { + log.error(`Failed to track event ${event}`, error); + } + } + + private async getClient(): Promise { + if (!this.initializationPromise) this.init(); + return this.client || this.initializationPromise; + } + + private isSkipped(): boolean { + if (this.disabled) return true; + if (typeof window === "undefined") return true; + const dappOrigin = window.location?.origin || ""; + try { + const url = new URL(dappOrigin); + return ( + url.protocol !== "https:" || + url.hostname === "localhost" || + url.hostname === "127.0.0.1" || + url.hostname === "::1" || + url.hostname === "[::1]" + ); + } catch { + return true; + } + } +} + +function sanitizeErrorMessage(message: string): string { + return message + .replace(/\beyJ[\w-]+\.[\w-]+\.[\w-]+\b/g, "[REDACTED_TOKEN]") + .replace(/\b(?:0x)?[a-fA-F0-9]{64,}\b/g, "[REDACTED_KEY]") + .replace(/\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/gi, "[REDACTED_EMAIL]") + .slice(0, 500); +} + +const CONNECTION_TRACK_ALLOWED_KEYS = new Set(["login_method", "verifier", "auth_connection", "is_aggregate_verifier"]); + +function sanitizeConnectionTrackDataForStorage(trackData: Record): Record { + const sanitized: Record = {}; + Object.entries(trackData).forEach(([key, value]) => { + if (!CONNECTION_TRACK_ALLOWED_KEYS.has(key)) return; + if (typeof value === "string" || typeof value === "number" || typeof value === "boolean" || value === null) { + sanitized[key] = value; + } + }); + return sanitized; +} + +export const CONNECTION_TRACK_STORAGE_KEY = "web3auth_mpc_connection_track"; + +export function persistPendingConnectionTrackData(trackData: Record): void { + if (typeof window === "undefined") return; + try { + const sanitizedTrackData = sanitizeConnectionTrackDataForStorage(trackData); + window.sessionStorage.setItem(CONNECTION_TRACK_STORAGE_KEY, JSON.stringify(sanitizedTrackData)); + } catch (error) { + log.error("Failed to persist pending connection track data", error); + } +} + +export function consumePendingConnectionTrackData(): Record | undefined { + if (typeof window === "undefined") return undefined; + try { + const raw = window.sessionStorage.getItem(CONNECTION_TRACK_STORAGE_KEY); + if (!raw) return undefined; + window.sessionStorage.removeItem(CONNECTION_TRACK_STORAGE_KEY); + const parsed = JSON.parse(raw) as unknown; + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return undefined; + return sanitizeConnectionTrackDataForStorage(parsed as Record); + } catch (error) { + log.error("Failed to consume pending connection track data", error); + return undefined; + } +} + +export function getErrorAnalyticsProperties(error: unknown): { error_code?: number | string; error_message: string } { + const analyticsError = error as { code?: number | string; message?: string }; + const message = analyticsError?.message || String(error) || "Unknown error"; + return { + ...(analyticsError?.code !== undefined ? { error_code: analyticsError.code } : {}), + error_message: sanitizeErrorMessage(message), + }; +} + +export function getInputFactorFailureReason(error: unknown): InputFactorFailureReason { + const analyticsError = error as { code?: number; message?: string }; + if ( + analyticsError?.code === 1207 || + analyticsError?.code === 1209 || + /invalid factor\s*key/i.test(analyticsError?.message || "") || + analyticsError?.message?.toLowerCase().includes("no metadata found") + ) { + return "invalid_factor"; + } + return "infra_error"; +} diff --git a/src/index.ts b/src/index.ts index 3e41424..e60b719 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,4 @@ +export * from "./analytics"; export * from "./constants"; export * from "./helper"; export * from "./interfaces"; diff --git a/src/interfaces.ts b/src/interfaces.ts index fec6ca3..7e88278 100644 --- a/src/interfaces.ts +++ b/src/interfaces.ts @@ -237,9 +237,9 @@ export interface ICoreKit { * * @param enableMFAParams - Parameters for recovery factor for MFA. * @param recoveryFactor - Default is true. If false, recovery factor will NOT be created. - * @returns The backup factor key if if recoveryFacort is true else empty string. + * @returns The backup factor key when recoveryFactor is true; otherwise undefined. */ - enableMFA(enableMFAParams: EnableMFAParams, recoveryFactor?: boolean): Promise; + enableMFA(enableMFAParams: EnableMFAParams, recoveryFactor?: boolean): Promise; /** * Second step for login where the user inputs their factor key. @@ -378,6 +378,16 @@ export interface Web3AuthOptions { */ enableLogging?: boolean; + /** + * Disables anonymous SDK usage analytics. + * + * Analytics are enabled for browser integrations on secure, non-localhost origins. + * Events include `web3auth_network` so dashboards can filter mainnet vs devnet. + * + * @defaultValue `false` + */ + disableAnalytics?: boolean; + /** * This option is used to specify the url path where user will be * redirected after login. Redirect Uri for OAuth is baseUrl/redirectPathName. diff --git a/src/mpcCoreKit.ts b/src/mpcCoreKit.ts index 3cae7ff..f4eda3e 100644 --- a/src/mpcCoreKit.ts +++ b/src/mpcCoreKit.ts @@ -29,6 +29,17 @@ import BN from "bn.js"; import bowser from "bowser"; import { ec as EC } from "elliptic"; +import { + Analytics, + ANALYTICS_EVENTS, + ANALYTICS_INTEGRATION_TYPE, + ANALYTICS_SDK_NAME, + ANALYTICS_SDK_VERSION, + consumePendingConnectionTrackData, + getErrorAnalyticsProperties, + getInputFactorFailureReason, + persistPendingConnectionTrackData, +} from "./analytics"; import { ERRORS, FactorKeyTypeShareDescription, @@ -114,6 +125,12 @@ export class Web3AuthMPCCoreKit implements ICoreKit { private socketTransports: string[] = ["websocket", "polling"]; + private analytics: Analytics; + + private suppressFactorAnalytics = false; + + private skipInitAnalytics = false; + constructor(options: Web3AuthOptions) { if (!options.web3AuthClientId) { throw CoreKitError.clientIdInvalid(); @@ -139,6 +156,7 @@ export class Web3AuthMPCCoreKit implements ICoreKit { if (!options.disableHashedFactorKey) options.disableHashedFactorKey = false; if (!options.hashedFactorNonce) options.hashedFactorNonce = options.web3AuthClientId; if (options.disableSessionManager === undefined) options.disableSessionManager = false; + if (options.disableAnalytics === undefined) options.disableAnalytics = false; if (options.socketTransports) this.socketTransports = options.socketTransports; this.options = options as Web3AuthOptionsWithDefaults; @@ -150,6 +168,20 @@ export class Web3AuthMPCCoreKit implements ICoreKit { }); } + this.analytics = new Analytics({ + disabled: options.disableAnalytics || isNodejsOrRN, + }); + this.analytics.setGlobalProperties({ + integration_type: ANALYTICS_INTEGRATION_TYPE, + dapp_url: typeof window === "undefined" ? undefined : window.location?.origin, + sdk_name: ANALYTICS_SDK_NAME, + sdk_version: ANALYTICS_SDK_VERSION, + web3auth_client_id: this.options.web3AuthClientId, + web3auth_network: this.options.web3AuthNetwork, + auth_ux_mode: this.options.uxMode, + key_type: this.keyType, + }); + TorusUtils.setSessionTime(this.options.sessionTime); } @@ -228,7 +260,12 @@ export class Web3AuthMPCCoreKit implements ICoreKit { const { tssIndex, tssShare } = await this.tKey.getTSSShare(factorKeyBNInput); if (tssIndexes.includes(tssIndex)) { // reset instance before throw error - await this.init(); + this.skipInitAnalytics = true; + try { + await this.init(); + } finally { + this.skipInitAnalytics = false; + } throw CoreKitError.duplicateTssIndex(); } tssIndexes.push(tssIndex); @@ -238,94 +275,139 @@ export class Web3AuthMPCCoreKit implements ICoreKit { const finalKey = lagrangeInterpolation(this.tkey.tssCurve, tssShares, tssIndexesBN); // reset instance after recovery completed - await this.init(); + this.skipInitAnalytics = true; + try { + await this.init(); + } finally { + this.skipInitAnalytics = false; + } return finalKey.toString("hex", 64); } public async init(params: InitParams = { handleRedirectResult: true }): Promise { - this.resetState(); - if (params.rehydrate === undefined) params.rehydrate = true; - - const nodeDetails = fetchLocalConfig(this.options.web3AuthNetwork, this.keyType); - - if (this.keyType === KEY_TYPE.ED25519 && this.options.useDKG) { - throw CoreKitError.invalidConfig("DKG is not supported for ed25519 key type"); - } - - this.torusSp = new TSSTorusServiceProvider({ - customAuthArgs: { - web3AuthClientId: this.options.web3AuthClientId, - baseUrl: this.options.baseUrl, - uxMode: this.isNodejsOrRN(this.options.uxMode) ? UX_MODE.REDIRECT : (this.options.uxMode as UX_MODE_TYPE), - network: this.options.web3AuthNetwork, - redirectPathName: this.options.redirectPathName, - locationReplaceOnRedirect: true, - serverTimeOffset: this.options.serverTimeOffset, - keyType: this.keyType, - useDkg: this.options.useDKG, - }, - }); + const startTime = Date.now(); + this.analytics.init(); + if (!this.skipInitAnalytics) { + void this.analytics.identify(this.options.web3AuthClientId, { + web3auth_client_id: this.options.web3AuthClientId, + web3auth_network: this.options.web3AuthNetwork, + }); + } + let skipSdkInitializationFailed = false; - this.storageLayer = new TorusStorageLayer({ - hostUrl: `${new URL(nodeDetails.torusNodeEndpoints[0]).origin}/metadata`, - enableLogging: this.enableLogging, - }); + try { + this.resetState(); + if (params.rehydrate === undefined) params.rehydrate = true; - const shareSerializationModule = new ShareSerializationModule(); - - this.tkey = new TKeyTSS({ - enableLogging: this.enableLogging, - serviceProvider: this.torusSp, - storageLayer: this.storageLayer, - manualSync: this.options.manualSync, - modules: { - shareSerialization: shareSerializationModule, - }, - tssKeyType: this.keyType, - }); + const nodeDetails = fetchLocalConfig(this.options.web3AuthNetwork, this.keyType); - if (this.isRedirectMode) { - await this.torusSp.init({ skipSw: true, skipPrefetch: true }); - } else if (this.options.uxMode === UX_MODE.POPUP) { - await this.torusSp.init({}); - } + if (this.keyType === KEY_TYPE.ED25519 && this.options.useDKG) { + throw CoreKitError.invalidConfig("DKG is not supported for ed25519 key type"); + } - this.ready = true; + this.torusSp = new TSSTorusServiceProvider({ + customAuthArgs: { + web3AuthClientId: this.options.web3AuthClientId, + baseUrl: this.options.baseUrl, + uxMode: this.isNodejsOrRN(this.options.uxMode) ? UX_MODE.REDIRECT : (this.options.uxMode as UX_MODE_TYPE), + network: this.options.web3AuthNetwork, + redirectPathName: this.options.redirectPathName, + locationReplaceOnRedirect: true, + serverTimeOffset: this.options.serverTimeOffset, + keyType: this.keyType, + useDkg: this.options.useDKG, + }, + }); - // try handle redirect flow if enabled and return(redirect) from oauth login - if ( - params.handleRedirectResult && - this.options.uxMode === UX_MODE.REDIRECT && - (window?.location.hash.includes("#state") || window?.location.hash.includes("#access_token")) - ) { - // on failed redirect, instance is reseted. - // skip check feature gating on redirection as it was check before login - await this.handleRedirectResult(); + this.storageLayer = new TorusStorageLayer({ + hostUrl: `${new URL(nodeDetails.torusNodeEndpoints[0]).origin}/metadata`, + enableLogging: this.enableLogging, + }); - // return early on successful redirect, the rest of the code will not be executed - return; - } else if (params.rehydrate && this.sessionManager) { - // if not redirect flow try to rehydrate session if available - const sessionId = await this.currentStorage.get("sessionId"); - if (sessionId) { - this.sessionManager.sessionId = sessionId; - - // swallowed, should not throw on rehydrate timed out session - const sessionResult = await this.sessionManager.authorizeSession().catch(async (err) => { - log.error("rehydrate session error", err); - }); + const shareSerializationModule = new ShareSerializationModule(); + + this.tkey = new TKeyTSS({ + enableLogging: this.enableLogging, + serviceProvider: this.torusSp, + storageLayer: this.storageLayer, + manualSync: this.options.manualSync, + modules: { + shareSerialization: shareSerializationModule, + }, + tssKeyType: this.keyType, + }); - // try rehydrate session - if (sessionResult) { - await this.rehydrateSession(sessionResult); + if (this.isRedirectMode) { + await this.torusSp.init({ skipSw: true, skipPrefetch: true }); + } else if (this.options.uxMode === UX_MODE.POPUP) { + await this.torusSp.init({}); + } - // return early on success rehydration + this.ready = true; + + // try handle redirect flow if enabled and return(redirect) from oauth login + if ( + params.handleRedirectResult && + this.options.uxMode === UX_MODE.REDIRECT && + (window?.location.hash.includes("#state") || window?.location.hash.includes("#access_token")) + ) { + // on failed redirect, instance is reseted. + // skip check feature gating on redirection as it was check before login + // Connection Started/Completed/Failed are tracked inside handleRedirectResult. + // Login errors from redirect must not also count as SDK initialization failures. + try { + await this.handleRedirectResult(); + } catch (error) { + skipSdkInitializationFailed = true; + throw error; + } + this.trackInitializationCompleted(startTime, { session_rehydration: "skipped" }); + + // return early on successful redirect, the rest of the code will not be executed + return; + } else if (params.rehydrate && this.sessionManager) { + // if not redirect flow try to rehydrate session if available + const sessionId = await this.currentStorage.get("sessionId"); + if (sessionId) { + this.sessionManager.sessionId = sessionId; + const rehydrationStartTime = Date.now(); + + // swallowed, should not throw on rehydrate timed out session + const sessionResult = await this.sessionManager.authorizeSession().catch(async (err) => { + log.error("rehydrate session error", err); + void this.analytics.track(ANALYTICS_EVENTS.SESSION_REHYDRATION_FAILED, { + ...getErrorAnalyticsProperties(err), + duration: Date.now() - rehydrationStartTime, + }); + }); + + // try rehydrate session + if (sessionResult) { + const rehydrated = await this.rehydrateSession(sessionResult); + this.trackInitializationCompleted(startTime, { session_rehydration: rehydrated ? "completed" : "failed" }); + + // return early whether rehydrate succeeded or swallowed an error + return; + } + + await this.featureRequest(); + this.trackInitializationCompleted(startTime, { session_rehydration: "failed" }); return; } } + // feature gating if not redirect flow or session rehydration + await this.featureRequest(); + this.trackInitializationCompleted(startTime, { session_rehydration: "skipped" }); + } catch (error) { + if (!this.skipInitAnalytics && !skipSdkInitializationFailed) { + void this.analytics.track(ANALYTICS_EVENTS.SDK_INITIALIZATION_FAILED, { + ...this.getInitializationTrackData(), + ...getErrorAnalyticsProperties(error), + duration: Date.now() - startTime, + }); + } + throw error; } - // feature gating if not redirect flow or session rehydration - await this.featureRequest(); } public async loginWithOAuth(params: OAuthLoginParams): Promise { @@ -348,6 +430,17 @@ export class Web3AuthMPCCoreKit implements ICoreKit { if (this.isRedirectMode && (importTssKey || registerExistingSFAKey)) { throw CoreKitError.invalidConfig("key import is not supported in redirect mode"); } + const startTime = Date.now(); + const trackData = this.getConnectionTrackData(params); + // Redirect unloads the page before Segment can reliably send. Persist the + // connection properties so handleRedirectResult can emit Connection Started with + // the same verifier / auth_connection. If triggerLogin throws before unload, + // emit start here so Connection Failed still has a matching funnel start. + if (this.isRedirectMode) { + persistPendingConnectionTrackData(trackData); + } else { + void this.analytics.track(ANALYTICS_EVENTS.CONNECTION_STARTED, trackData); + } try { // oAuth login. const verifierParams = params as SubVerifierDetailsParams; @@ -391,13 +484,28 @@ export class Web3AuthMPCCoreKit implements ICoreKit { } else { await this.setupTkey(importTssKey, loginResponse, false); } + this.trackConnectionOutcome(startTime, trackData); } catch (err: unknown) { log.error("login error", err); if (err instanceof CoreError) { if (err.code === 1302) { + if (this.isRedirectMode) { + consumePendingConnectionTrackData(); + void this.analytics.track(ANALYTICS_EVENTS.CONNECTION_STARTED, trackData); + } + this.trackRequiredShare(startTime, trackData); throw CoreKitError.default(ERRORS.TKEY_SHARES_REQUIRED); } } + if (this.isRedirectMode) { + consumePendingConnectionTrackData(); + void this.analytics.track(ANALYTICS_EVENTS.CONNECTION_STARTED, trackData); + } + void this.analytics.track(ANALYTICS_EVENTS.CONNECTION_FAILED, { + ...trackData, + ...getErrorAnalyticsProperties(err), + duration: Date.now() - startTime, + }); throw CoreKitError.default((err as Error).message); } } @@ -422,6 +530,9 @@ export class Web3AuthMPCCoreKit implements ICoreKit { throw CoreKitError.invalidConfig("Cannot import TSS key and register SFA key at the same time."); } + const startTime = Date.now(); + const trackData = this.getJWTTrackData(params); + void this.analytics.track(ANALYTICS_EVENTS.CONNECTION_STARTED, trackData); try { // prefetch tss pub keys. const prefetchTssPubs = []; @@ -466,15 +577,22 @@ export class Web3AuthMPCCoreKit implements ICoreKit { } else { await this.setupTkey(importTssKey, loginResponse, false); } + this.trackConnectionOutcome(startTime, trackData); } catch (err: unknown) { log.error("login error", err); if (err instanceof CoreError) { if (err.code === 1302) { + this.trackRequiredShare(startTime, trackData); const newError = CoreKitError.default(ERRORS.TKEY_SHARES_REQUIRED); newError.stack = err.stack; throw newError; } } + void this.analytics.track(ANALYTICS_EVENTS.CONNECTION_FAILED, { + ...trackData, + ...getErrorAnalyticsProperties(err), + duration: Date.now() - startTime, + }); const newError = CoreKitError.default((err as Error).message); newError.stack = (err as Error).stack; throw newError; @@ -487,6 +605,12 @@ export class Web3AuthMPCCoreKit implements ICoreKit { public async handleRedirectResult(): Promise { this.checkReady(); + const startTime = Date.now(); + let connectionTrackData: Record = { + ...consumePendingConnectionTrackData(), + login_method: "redirect", + }; + void this.analytics.track(ANALYTICS_EVENTS.CONNECTION_STARTED, connectionTrackData); try { const result = await this.torusSp.customAuthInstance.getRedirectResult(); @@ -522,25 +646,48 @@ export class Web3AuthMPCCoreKit implements ICoreKit { } const userInfo = this.getUserInfo(); + connectionTrackData = this.enrichRedirectConnectionTrackData(connectionTrackData, userInfo); if (!this.state.postBoxKey) { throw CoreKitError.postBoxKeyMissing("postBoxKey not present in state after processing redirect result."); } this.torusSp.postboxKey = new BN(this.state.postBoxKey, "hex"); this.torusSp.verifierId = userInfo.verifierId; await this.setupTkey(); + this.trackConnectionOutcome(startTime, connectionTrackData); } catch (error: unknown) { + const { userInfo } = this.state; + connectionTrackData = this.enrichRedirectConnectionTrackData(connectionTrackData, userInfo); + const isRequiredShare = error instanceof CoreError && error.code === 1302; + if (isRequiredShare) { + this.trackRequiredShare(startTime, connectionTrackData); + } this.resetState(); log.error("error while handling redirect result", error); + if (!isRequiredShare) { + void this.analytics.track(ANALYTICS_EVENTS.CONNECTION_FAILED, { + ...connectionTrackData, + ...getErrorAnalyticsProperties(error), + duration: Date.now() - startTime, + }); + } throw CoreKitError.default((error as Error).message); } } public async inputFactorKey(factorKey: BN): Promise { this.checkReady(); + const startTime = Date.now(); + const completesLogin = this.status === COREKIT_STATUS.REQUIRED_SHARE; + if (!this.suppressFactorAnalytics) void this.analytics.track(ANALYTICS_EVENTS.INPUT_FACTOR_STARTED); try { // always check for valid factor key - const factorKeyPrivate = factorKeyCurve.keyFromPrivate(factorKey.toBuffer()); - const factorPubX = factorKeyPrivate.getPublic().getX().toString("hex").padStart(64, "0"); + let factorPubX: string; + try { + const factorKeyPrivate = factorKeyCurve.keyFromPrivate(factorKey.toBuffer()); + factorPubX = factorKeyPrivate.getPublic().getX().toString("hex").padStart(64, "0"); + } catch { + throw CoreKitError.providedFactorKeyInvalid("Invalid FactorKey provided. Failed to derive its public key."); + } const factorEncExist = this.tkey.metadata.factorEncs?.[this.tkey.tssTag]?.[factorPubX]; if (!factorEncExist) { throw CoreKitError.providedFactorKeyInvalid("Invalid FactorKey provided. Failed to input factor key."); @@ -556,8 +703,28 @@ export class Web3AuthMPCCoreKit implements ICoreKit { // Finalize initialization. await this.tKey.reconstructKey(); await this.finalizeTkey(factorKey); + if (!this.suppressFactorAnalytics) { + void this.analytics.track(ANALYTICS_EVENTS.INPUT_FACTOR_COMPLETED, { + factor_share_type: this.state.tssShareIndex, + duration: Date.now() - startTime, + }); + } + if (completesLogin && !this.suppressFactorAnalytics) { + void this.analytics.track(ANALYTICS_EVENTS.CONNECTION_COMPLETED, { + completion_method: "input_factor", + factor_share_type: this.state.tssShareIndex, + duration: Date.now() - startTime, + }); + } } catch (err: unknown) { log.error("login error", err); + if (!this.suppressFactorAnalytics) { + void this.analytics.track(ANALYTICS_EVENTS.INPUT_FACTOR_FAILED, { + failure_reason: getInputFactorFailureReason(err), + ...getErrorAnalyticsProperties(err), + duration: Date.now() - startTime, + }); + } if (err instanceof CoreError) { if (err.code === 1302) { throw CoreKitError.default(ERRORS.TKEY_SHARES_REQUIRED); @@ -590,58 +757,82 @@ export class Web3AuthMPCCoreKit implements ICoreKit { } } - public async enableMFA(enableMFAParams: EnableMFAParams, recoveryFactor = true): Promise { + public async enableMFA(enableMFAParams: EnableMFAParams, recoveryFactor = true): Promise { this.checkReady(); + const startTime = Date.now(); + let mutationStarted = false; + void this.analytics.track(ANALYTICS_EVENTS.MFA_ENABLEMENT_STARTED, { + auth_ux_mode: this.options.uxMode, + recovery_factor_enabled: recoveryFactor, + }); - const { postBoxKey } = this.state; - const hashedFactorKey = getHashedPrivateKey(postBoxKey, this.options.hashedFactorNonce); - if (!(await this.checkIfFactorKeyValid(hashedFactorKey))) { - if (this.tKey._localMetadataTransitions[0].length) { - throw CoreKitError.commitChangesBeforeMFA(); + try { + const { postBoxKey } = this.state; + const hashedFactorKey = getHashedPrivateKey(postBoxKey, this.options.hashedFactorNonce); + if (!(await this.checkIfFactorKeyValid(hashedFactorKey))) { + if (this.tKey._localMetadataTransitions[0].length) { + throw CoreKitError.commitChangesBeforeMFA(); + } + throw CoreKitError.mfaAlreadyEnabled(); } - throw CoreKitError.mfaAlreadyEnabled(); - } - return this.atomicSync(async () => { - let browserData; - - if (this.isNodejsOrRN(this.options.uxMode)) { - browserData = { - browserName: "Node Env", - browserVersion: "", - deviceName: "nodejs", - }; - } else { - // try { - const browserInfo = bowser.parse(navigator.userAgent); - const browserName = `${browserInfo.browser.name}`; - browserData = { - browserName, - browserVersion: browserInfo.browser.version, - deviceName: browserInfo.os.name, - }; - } - const deviceFactorKey = new BN(await this.createFactor({ shareType: TssShareType.DEVICE, additionalMetadata: browserData }), "hex"); - await this.setDeviceFactor(deviceFactorKey); - await this.inputFactorKey(new BN(deviceFactorKey, "hex")); + mutationStarted = true; + this.suppressFactorAnalytics = true; + const backupFactorKey = await this.atomicSync(async () => { + let browserData; + + if (this.isNodejsOrRN(this.options.uxMode)) { + browserData = { + browserName: "Node Env", + browserVersion: "", + deviceName: "nodejs", + }; + } else { + const browserInfo = bowser.parse(navigator.userAgent); + const browserName = `${browserInfo.browser.name}`; + browserData = { + browserName, + browserVersion: browserInfo.browser.version, + deviceName: browserInfo.os.name, + }; + } + const deviceFactorKey = new BN(await this.createFactor({ shareType: TssShareType.DEVICE, additionalMetadata: browserData }), "hex"); + await this.setDeviceFactor(deviceFactorKey); + await this.inputFactorKey(new BN(deviceFactorKey, "hex")); - const hashedFactorPub = getPubKeyPoint(hashedFactorKey, factorKeyCurve); - await this.deleteFactor(hashedFactorPub, hashedFactorKey); + const hashedFactorPub = getPubKeyPoint(hashedFactorKey, factorKeyCurve); + await this.deleteFactor(hashedFactorPub, hashedFactorKey); - // only recovery factor = true - let backupFactorKey; - if (recoveryFactor) { - backupFactorKey = await this.createFactor({ shareType: TssShareType.RECOVERY, ...enableMFAParams }); - } + // only recovery factor = true + let recoveryFactorKey: string | undefined; + if (recoveryFactor) { + recoveryFactorKey = await this.createFactor({ shareType: TssShareType.RECOVERY, ...enableMFAParams }); + } - // update to undefined for next major release + return recoveryFactorKey; + }); + this.suppressFactorAnalytics = false; + void this.analytics.track(ANALYTICS_EVENTS.MFA_ENABLEMENT_COMPLETED, { + auth_ux_mode: this.options.uxMode, + is_mfa_enabled: true, + recovery_factor_created: Boolean(backupFactorKey), + duration: Date.now() - startTime, + }); return backupFactorKey; - }).catch((reason: Error) => { - log.error("error enabling MFA:", reason.message); - const err = CoreKitError.default(reason.message); - err.stack = reason.stack; + } catch (reason) { + this.suppressFactorAnalytics = false; + const error = reason as Error; + log.error("error enabling MFA:", error.message); + void this.analytics.track(ANALYTICS_EVENTS.MFA_ENABLEMENT_FAILED, { + auth_ux_mode: this.options.uxMode, + ...getErrorAnalyticsProperties(reason), + duration: Date.now() - startTime, + }); + if (!mutationStarted) throw reason; + const err = CoreKitError.default(error.message); + err.stack = error.stack; throw err; - }); + } } public getTssFactorPub = (): string[] => { @@ -656,41 +847,62 @@ export class Web3AuthMPCCoreKit implements ICoreKit { // mutation function public async createFactor(createFactorParams: CreateFactorParams): Promise { this.checkReady(); + const startTime = Date.now(); const { shareType } = createFactorParams; + const shareDescription = createFactorParams.shareDescription || FactorKeyTypeShareDescription.Other; + let mutationStarted = false; - let { factorKey, shareDescription, additionalMetadata } = createFactorParams; + try { + let { factorKey, additionalMetadata } = createFactorParams; - if (!VALID_SHARE_INDICES.includes(shareType)) { - throw CoreKitError.newShareIndexInvalid(`Invalid share type provided (${shareType}). Valid share types are ${VALID_SHARE_INDICES}.`); - } - if (!factorKey) { - factorKey = generateFactorKey().private; - } - if (!shareDescription) { - shareDescription = FactorKeyTypeShareDescription.Other; - } - if (!additionalMetadata) { - additionalMetadata = {}; - } + if (!VALID_SHARE_INDICES.includes(shareType)) { + throw CoreKitError.newShareIndexInvalid(`Invalid share type provided (${shareType}). Valid share types are ${VALID_SHARE_INDICES}.`); + } + if (!factorKey) { + factorKey = generateFactorKey().private; + } + if (!additionalMetadata) { + additionalMetadata = {}; + } - const factorPub = getPubKeyPoint(factorKey, factorKeyCurve); + const factorPub = getPubKeyPoint(factorKey, factorKeyCurve); - if (this.getTssFactorPub().includes(factorPub.toSEC1(factorKeyCurve, true).toString("hex"))) { - throw CoreKitError.factorKeyAlreadyExists(); - } + if (this.getTssFactorPub().includes(factorPub.toSEC1(factorKeyCurve, true).toString("hex"))) { + throw CoreKitError.factorKeyAlreadyExists(); + } - return this.atomicSync(async () => { - await this.copyOrCreateShare(shareType, factorPub); - await this.backupMetadataShare(factorKey); - await this.addFactorDescription({ factorKey, shareDescription, additionalMetadata, updateMetadata: false }); + mutationStarted = true; + const result = await this.atomicSync(async () => { + await this.copyOrCreateShare(shareType, factorPub); + await this.backupMetadataShare(factorKey); + await this.addFactorDescription({ factorKey, shareDescription, additionalMetadata, updateMetadata: false }); - return scalarBNToBufferSEC1(factorKey).toString("hex"); - }).catch((reason: Error) => { - log.error("error creating factor:", reason.message); - const err = CoreKitError.default(`error creating factor: ${reason.message}`); - err.stack = reason.stack; + return scalarBNToBufferSEC1(factorKey).toString("hex"); + }); + if (!this.suppressFactorAnalytics) { + void this.analytics.track(ANALYTICS_EVENTS.FACTOR_CREATION_COMPLETED, { + factor_share_type: shareType, + share_description: shareDescription, + duration: Date.now() - startTime, + }); + } + return result; + } catch (reason) { + const error = reason as Error; + log.error("error creating factor:", error.message); + if (!this.suppressFactorAnalytics) { + void this.analytics.track(ANALYTICS_EVENTS.FACTOR_CREATION_FAILED, { + factor_share_type: shareType, + share_description: shareDescription, + ...getErrorAnalyticsProperties(reason), + duration: Date.now() - startTime, + }); + } + if (!mutationStarted) throw reason; + const err = CoreKitError.default(`error creating factor: ${error.message}`); + err.stack = error.stack; throw err; - }); + } } /** @@ -698,6 +910,9 @@ export class Web3AuthMPCCoreKit implements ICoreKit { */ public getPubKey(): Buffer { const { tssPubKey } = this.state; + if (!tssPubKey) { + throw CoreKitError.tssPublicKeyOrEndpointsMissing("tssPubKey not present in state when getting public key."); + } return Buffer.from(tssPubKey); } @@ -706,6 +921,9 @@ export class Web3AuthMPCCoreKit implements ICoreKit { */ public getPubKeyPoint(): Point { const { tssPubKey } = this.state; + if (!tssPubKey) { + throw CoreKitError.tssPublicKeyOrEndpointsMissing("tssPubKey not present in state when getting public key point."); + } return Point.fromSEC1(this.tkey.tssCurve, tssPubKey.toString("hex")); } @@ -826,53 +1044,82 @@ export class Web3AuthMPCCoreKit implements ICoreKit { // mutation function async deleteFactor(factorPub: Point, factorKey?: BNString): Promise { - if (!this.state.factorKey) { - throw CoreKitError.factorKeyNotPresent("factorKey not present in state when deleting a factor."); - } - if (!this.tKey.metadata.factorPubs) { - throw CoreKitError.factorPubsMissing(); - } - - await this.atomicSync(async () => { - const remainingFactors = this.tKey.metadata.factorPubs[this.tKey.tssTag].length || 0; - if (remainingFactors <= 1) { - throw CoreKitError.cannotDeleteLastFactor("Cannot delete last factor"); + const startTime = Date.now(); + try { + if (!this.state.factorKey) { + throw CoreKitError.factorKeyNotPresent("factorKey not present in state when deleting a factor."); } - const fpp = factorPub; - const stateFpp = getPubKeyPoint(this.state.factorKey, factorKeyCurve); - if (fpp.equals(stateFpp)) { - throw CoreKitError.factorInUseCannotBeDeleted("Cannot delete current active factor"); + if (!this.tKey.metadata.factorPubs) { + throw CoreKitError.factorPubsMissing(); } - await this.tKey.deleteFactorPub({ factorKey: this.state.factorKey, deleteFactorPub: factorPub, authSignatures: this.signatures }); - const factorPubHex = fpp.toSEC1(factorKeyCurve, true).toString("hex"); - const allDesc = this.tKey.metadata.getShareDescription(); - const keyDesc = allDesc[factorPubHex]; - if (keyDesc) { - await Promise.all(keyDesc.map(async (desc) => this.tKey?.metadata.deleteShareDescription(factorPubHex, desc))); - } + await this.atomicSync(async () => { + const remainingFactors = this.tKey.metadata.factorPubs[this.tKey.tssTag].length || 0; + if (remainingFactors <= 1) { + throw CoreKitError.cannotDeleteLastFactor("Cannot delete last factor"); + } + const fpp = factorPub; + const stateFpp = getPubKeyPoint(this.state.factorKey, factorKeyCurve); + if (fpp.equals(stateFpp)) { + throw CoreKitError.factorInUseCannotBeDeleted("Cannot delete current active factor"); + } + + await this.tKey.deleteFactorPub({ factorKey: this.state.factorKey, deleteFactorPub: factorPub, authSignatures: this.signatures }); + const factorPubHex = fpp.toSEC1(factorKeyCurve, true).toString("hex"); + const allDesc = this.tKey.metadata.getShareDescription(); + const keyDesc = allDesc[factorPubHex]; + if (keyDesc) { + await Promise.all(keyDesc.map(async (desc) => this.tKey?.metadata.deleteShareDescription(factorPubHex, desc))); + } - // delete factorKey share metadata if factorkey is provided - if (factorKey) { - const factorKeyBN = new BN(factorKey, "hex"); - const derivedFactorPub = getPubKeyPoint(factorKeyBN, factorKeyCurve); - // only delete if factorPub matches - if (derivedFactorPub.equals(fpp)) { - await this.deleteMetadataShareBackup(factorKeyBN); + // delete factorKey share metadata if factorkey is provided + if (factorKey) { + const factorKeyBN = new BN(factorKey, "hex"); + const derivedFactorPub = getPubKeyPoint(factorKeyBN, factorKeyCurve); + // only delete if factorPub matches + if (derivedFactorPub.equals(fpp)) { + await this.deleteMetadataShareBackup(factorKeyBN); + } } + }); + if (!this.suppressFactorAnalytics) { + void this.analytics.track(ANALYTICS_EVENTS.FACTOR_DELETION_COMPLETED, { duration: Date.now() - startTime }); } - }); + } catch (error) { + if (!this.suppressFactorAnalytics) { + void this.analytics.track(ANALYTICS_EVENTS.FACTOR_DELETION_FAILED, { + ...getErrorAnalyticsProperties(error), + duration: Date.now() - startTime, + }); + } + throw error; + } } public async logout(): Promise { - if (this.sessionManager?.sessionId) { - await this.sessionManager.invalidateSession(); - } - // to accommodate async storage - await this.currentStorage.set("sessionId", ""); + const startTime = Date.now(); + try { + if (this.sessionManager?.sessionId) { + await this.sessionManager.invalidateSession(); + } + // to accommodate async storage + await this.currentStorage.set("sessionId", ""); - this.resetState(); - await this.init({ handleRedirectResult: false, rehydrate: false }); + this.resetState(); + this.skipInitAnalytics = true; + try { + await this.init({ handleRedirectResult: false, rehydrate: false }); + } finally { + this.skipInitAnalytics = false; + } + void this.analytics.track(ANALYTICS_EVENTS.LOGOUT_COMPLETED, { duration: Date.now() - startTime }); + } catch (error) { + void this.analytics.track(ANALYTICS_EVENTS.LOGOUT_FAILED, { + ...getErrorAnalyticsProperties(error), + duration: Date.now() - startTime, + }); + throw error; + } } public getUserInfo(): UserInfo { @@ -1192,7 +1439,8 @@ export class Web3AuthMPCCoreKit implements ICoreKit { } } - private async rehydrateSession(result: SessionData) { + private async rehydrateSession(result: SessionData): Promise { + const startTime = Date.now(); try { this.checkReady(); @@ -1229,8 +1477,18 @@ export class Web3AuthMPCCoreKit implements ICoreKit { await this.setDeviceFactor(this.state.factorKey); } } + void this.analytics.track(ANALYTICS_EVENTS.SESSION_REHYDRATION_COMPLETED, { + factor_share_type: this.state.tssShareIndex, + duration: Date.now() - startTime, + }); + return true; } catch (err) { log.warn("failed to authorize session please use new instance without rehydration", err); + void this.analytics.track(ANALYTICS_EVENTS.SESSION_REHYDRATION_FAILED, { + ...getErrorAnalyticsProperties(err), + duration: Date.now() - startTime, + }); + return false; } } @@ -1269,6 +1527,7 @@ export class Web3AuthMPCCoreKit implements ICoreKit { await this.currentStorage.set("sessionId", sessionId); } catch (err) { log.error("error creating session", err); + void this.analytics.track(ANALYTICS_EVENTS.SESSION_CREATION_FAILED, getErrorAnalyticsProperties(err)); } } @@ -1398,6 +1657,85 @@ export class Web3AuthMPCCoreKit implements ICoreKit { await this.tKey?.addShareDescription(factorPub, JSON.stringify(params), updateMetadata); } + private getInitializationTrackData(): Record { + const storageType = typeof this.options.storage === "string" ? this.options.storage : "async" in this.options.storage ? "async_custom" : "custom"; + return { + auth_ux_mode: this.options.uxMode, + logging_enabled: this.enableLogging, + storage_type: storageType, + key_type: this.keyType, + manual_sync: this.options.manualSync, + hashed_factor_enabled: !this.options.disableHashedFactorKey, + session_manager_enabled: !this.options.disableSessionManager, + use_dkg: this.options.useDKG, + }; + } + + private trackInitializationCompleted(startTime: number, extra: Record = {}): void { + if (this.skipInitAnalytics) return; + void this.analytics.track(ANALYTICS_EVENTS.SDK_INITIALIZATION_COMPLETED, { + ...this.getInitializationTrackData(), + ...extra, + duration: Date.now() - startTime, + }); + } + + private getConnectionTrackData(params: OAuthLoginParams): Record { + if ("subVerifierDetails" in params) { + return { + login_method: "oauth", + verifier: params.subVerifierDetails.verifier, + auth_connection: params.subVerifierDetails.typeOfLogin, + is_aggregate_verifier: false, + }; + } + return { + login_method: "oauth", + verifier: params.aggregateVerifierIdentifier, + auth_connection: params.subVerifierDetailsArray[0]?.typeOfLogin, + is_aggregate_verifier: true, + }; + } + + private enrichRedirectConnectionTrackData(trackData: Record, userInfo?: UserInfo): Record { + return { + ...trackData, + login_method: "redirect", + verifier: trackData.verifier ?? userInfo?.aggregateVerifier ?? userInfo?.verifier, + auth_connection: trackData.auth_connection ?? userInfo?.typeOfLogin, + is_aggregate_verifier: trackData.is_aggregate_verifier ?? Boolean(userInfo?.aggregateVerifier), + }; + } + + private getJWTTrackData(params: JWTLoginParams): Record { + return { + login_method: "jwt", + verifier: params.verifier, + is_aggregate_verifier: Boolean(params.subVerifier), + is_sfa: true, + }; + } + + private trackConnectionOutcome(startTime: number, trackData: Record): void { + if (this.status === COREKIT_STATUS.LOGGED_IN) { + void this.analytics.track(ANALYTICS_EVENTS.CONNECTION_COMPLETED, { + ...trackData, + corekit_status: this.status, + duration: Date.now() - startTime, + }); + } else if (this.status === COREKIT_STATUS.REQUIRED_SHARE) { + this.trackRequiredShare(startTime, trackData); + } + } + + private trackRequiredShare(startTime: number, trackData: Record): void { + void this.analytics.track(ANALYTICS_EVENTS.LOGIN_REQUIRED_SHARE, { + ...trackData, + corekit_status: COREKIT_STATUS.REQUIRED_SHARE, + duration: Date.now() - startTime, + }); + } + private resetState(): void { this.ready = false; this.tkey = null; diff --git a/src/sdkVersion.ts b/src/sdkVersion.ts new file mode 100644 index 0000000..89e775c --- /dev/null +++ b/src/sdkVersion.ts @@ -0,0 +1,2 @@ +// Generated from package.json by scripts/sync-sdk-version.mjs. Do not edit. +export const ANALYTICS_SDK_VERSION = "3.6.0"; diff --git a/tests/analytics.spec.ts b/tests/analytics.spec.ts new file mode 100644 index 0000000..676ea91 --- /dev/null +++ b/tests/analytics.spec.ts @@ -0,0 +1,250 @@ +import assert from "assert"; +import test from "node:test"; + +import { + ANALYTICS_EVENTS, + ANALYTICS_SDK_VERSION, + Analytics, + AnalyticsClient, + consumePendingConnectionTrackData, + CONNECTION_TRACK_STORAGE_KEY, + getErrorAnalyticsProperties, + getInputFactorFailureReason, + persistPendingConnectionTrackData, + WEB3AUTH_NETWORK, +} from "../src"; +import { version as packageVersion } from "../package.json"; + +test("analytics SDK version matches package.json", () => { + assert.strictEqual(ANALYTICS_SDK_VERSION, packageVersion); +}); + +test("analytics identifies by client id and includes global properties", async () => { + const identifyCalls: unknown[][] = []; + const trackCalls: unknown[][] = []; + const originalWindow = globalThis.window; + Object.defineProperty(globalThis, "window", { + configurable: true, + value: { location: { origin: "https://example.com" } }, + }); + + try { + const client = { + identify: async (...args: unknown[]) => { + identifyCalls.push(args); + }, + track: async (...args: unknown[]) => { + trackCalls.push(args); + }, + } as unknown as AnalyticsClient; + const analytics = new Analytics({ + clientFactory: async () => client, + }); + analytics.setGlobalProperties({ sdk_name: "MPC Core Kit", duration: 99 }); + analytics.init(); + + await analytics.identify("client-id", { web3auth_client_id: "client-id" }); + await analytics.track(ANALYTICS_EVENTS.CONNECTION_COMPLETED, { duration: 10 }); + + assert.strictEqual(identifyCalls[0][0], "client-id"); + assert.deepStrictEqual(trackCalls[0], [ + ANALYTICS_EVENTS.CONNECTION_COMPLETED, + { sdk_name: "MPC Core Kit", duration: 10 }, + ]); + } finally { + Object.defineProperty(globalThis, "window", { configurable: true, value: originalWindow }); + } +}); + +test("analytics sends events on all networks", async () => { + const originalWindow = globalThis.window; + Object.defineProperty(globalThis, "window", { + configurable: true, + value: { location: { origin: "https://example.com" } }, + }); + try { + for (const network of [WEB3AUTH_NETWORK.DEVNET, WEB3AUTH_NETWORK.MAINNET]) { + const trackCalls: unknown[][] = []; + const analytics = new Analytics({ + clientFactory: async (): Promise => + ({ + identify: async (): Promise => undefined, + track: async (...args: unknown[]): Promise => { + trackCalls.push(args); + }, + }) as unknown as AnalyticsClient, + }); + analytics.setGlobalProperties({ web3auth_network: network }); + analytics.init(); + await analytics.track(ANALYTICS_EVENTS.CONNECTION_COMPLETED); + assert.strictEqual(trackCalls[0][0], ANALYTICS_EVENTS.CONNECTION_COMPLETED); + assert.deepStrictEqual(trackCalls[0][1], { web3auth_network: network }); + } + } finally { + Object.defineProperty(globalThis, "window", { configurable: true, value: originalWindow }); + } +}); + +test("analytics skips insecure and local origins", async () => { + const originalWindow = globalThis.window; + try { + for (const origin of ["http://example.com", "https://localhost", "https://127.0.0.1", "https://[::1]", "null"]) { + let factoryCalled = false; + Object.defineProperty(globalThis, "window", { + configurable: true, + value: { location: { origin } }, + }); + const analytics = new Analytics({ + clientFactory: async () => { + factoryCalled = true; + return {} as AnalyticsClient; + }, + }); + analytics.init(); + await analytics.track(ANALYTICS_EVENTS.SDK_INITIALIZATION_COMPLETED); + assert.strictEqual(factoryCalled, false, `analytics should skip ${origin}`); + } + } finally { + Object.defineProperty(globalThis, "window", { configurable: true, value: originalWindow }); + } +}); + +test("analytics uses a thenable Segment client instead of the resolved tuple", async () => { + const originalWindow = globalThis.window; + Object.defineProperty(globalThis, "window", { + configurable: true, + value: { location: { origin: "https://example.com" } }, + }); + try { + const trackCalls: unknown[][] = []; + const client = { + identify: async (): Promise => undefined, + track: async (...args: unknown[]): Promise => { + trackCalls.push(args); + }, + then(onFulfilled?: (value: unknown) => unknown) { + return Promise.resolve([this, {}]).then(onFulfilled); + }, + }; + const analytics = new Analytics({ + clientFactory: async () => client as unknown as AnalyticsClient, + }); + analytics.init(); + await analytics.track(ANALYTICS_EVENTS.CONNECTION_COMPLETED); + assert.strictEqual(trackCalls[0][0], ANALYTICS_EVENTS.CONNECTION_COMPLETED); + } finally { + Object.defineProperty(globalThis, "window", { configurable: true, value: originalWindow }); + } +}); + +test("analytics skips when no browser window is available", async () => { + const originalWindow = globalThis.window; + let factoryCalled = false; + Object.defineProperty(globalThis, "window", { configurable: true, value: undefined }); + try { + const analytics = new Analytics({ + clientFactory: async () => { + factoryCalled = true; + return {} as AnalyticsClient; + }, + }); + analytics.init(); + await analytics.track(ANALYTICS_EVENTS.SDK_INITIALIZATION_COMPLETED); + assert.strictEqual(factoryCalled, false); + } finally { + Object.defineProperty(globalThis, "window", { configurable: true, value: originalWindow }); + } +}); + +test("analytics can be disabled and never propagates client errors", async () => { + const disabledAnalytics = new Analytics({ + disabled: true, + clientFactory: async () => { + throw new Error("must not initialize"); + }, + }); + disabledAnalytics.init(); + await disabledAnalytics.track(ANALYTICS_EVENTS.CONNECTION_STARTED); + + const originalWindow = globalThis.window; + Object.defineProperty(globalThis, "window", { + configurable: true, + value: { location: { origin: "https://example.com" } }, + }); + try { + const failingAnalytics = new Analytics({ + clientFactory: async () => + ({ + identify: async () => { + throw new Error("identify failed"); + }, + track: async () => { + throw new Error("track failed"); + }, + }) as unknown as AnalyticsClient, + }); + failingAnalytics.init(); + await assert.doesNotReject(() => failingAnalytics.identify("client-id")); + await assert.doesNotReject(() => failingAnalytics.track(ANALYTICS_EVENTS.CONNECTION_STARTED)); + } finally { + Object.defineProperty(globalThis, "window", { configurable: true, value: originalWindow }); + } +}); + +test("input factor errors are split into user and infrastructure buckets", () => { + assert.strictEqual(getInputFactorFailureReason({ code: 1207, message: "invalid factor" }), "invalid_factor"); + assert.strictEqual(getInputFactorFailureReason({ code: 1209, message: "metadata missing" }), "invalid_factor"); + assert.strictEqual(getInputFactorFailureReason(new Error("invalid factor key")), "invalid_factor"); + assert.strictEqual(getInputFactorFailureReason(new Error("network unavailable")), "infra_error"); +}); + +test("analytics errors redact tokens and key material", () => { + const token = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.VerySecretSignature"; + const key = "a".repeat(64); + const email = "person@example.com"; + const properties = getErrorAnalyticsProperties(new Error(`token ${token} key ${key} email ${email}`)); + + assert.strictEqual(properties.error_message.includes(token), false); + assert.strictEqual(properties.error_message.includes(key), false); + assert.strictEqual(properties.error_message.includes(email), false); +}); + +test("pending connection track data survives a redirect and is consumed once", () => { + const originalWindow = globalThis.window; + const store = new Map(); + Object.defineProperty(globalThis, "window", { + configurable: true, + value: { + sessionStorage: { + setItem: (key: string, value: string) => { + store.set(key, value); + }, + getItem: (key: string) => store.get(key) ?? null, + removeItem: (key: string) => { + store.delete(key); + }, + }, + }, + }); + + try { + const trackData = { + login_method: "oauth", + verifier: "google-verifier", + auth_connection: "google", + is_aggregate_verifier: false, + }; + persistPendingConnectionTrackData({ + ...trackData, + id_token: "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.VerySecretSignature", + email: "person@example.com", + }); + const stored = JSON.parse(store.get(CONNECTION_TRACK_STORAGE_KEY) || "{}") as Record; + assert.deepStrictEqual(stored, trackData); + assert.strictEqual("id_token" in stored, false); + assert.deepStrictEqual(consumePendingConnectionTrackData(), trackData); + assert.strictEqual(consumePendingConnectionTrackData(), undefined); + } finally { + Object.defineProperty(globalThis, "window", { configurable: true, value: originalWindow }); + } +});