diff --git a/bun.lock b/bun.lock index e7c5fd3d..4b573a8e 100644 --- a/bun.lock +++ b/bun.lock @@ -196,12 +196,9 @@ "@noble/hashes": "^2.2.0", "@polkadot-api/json-rpc-provider": "^0.2.0", "@polkadot-api/json-rpc-provider-proxy": "^0.4.0", - "@polkadot-api/signer": "^0.3.3", "@polkadot-api/substrate-bindings": "0.20.3", "@polkadot-api/substrate-client": "0.7.0", "@polkadot-api/utils": "0.4.0", - "@polkadot-labs/hdkd": "^0.0.28", - "@polkadot-labs/hdkd-helpers": "^0.0.30", "polkadot-api": "^2.1.8", "smoldot": "^3.1.4", }, @@ -267,6 +264,7 @@ "name": "@dotli/truapi-debug", "version": "0.6.0", "dependencies": { + "@dotli/config": "workspace:*", "@dotli/shared": "workspace:*", "nanoevents": "^9.1.0", }, @@ -296,7 +294,7 @@ "@dotli/truapi-debug": "workspace:*", "@noble/hashes": "^2.2.0", "@parity/truapi": "0.5.1", - "@parity/truapi-host": "0.2.0", + "@parity/truapi-host": "0.2.1", "@polkadot-api/json-rpc-provider": "^0.2.0", "@scure/base": "^2.2.0", "neverthrow": "^8.2.0", @@ -308,6 +306,7 @@ "@dotli/typescript-config": "workspace:*", "@types/qrcode": "^1.5.6", "eslint": "^10.5.0", + "fake-indexeddb": "^6.2.5", "happy-dom": "^20.10.3", "typescript": "~6.0.3", "vite": "^8.0.16", @@ -323,8 +322,10 @@ }, "overrides": { "@parity/truapi": "0.5.1", + "brace-expansion": "^5.0.8", "esbuild": "^0.28.1", "fast-uri": "3.1.4", + "postcss": "^8.5.24", "smoldot": "3.3.1", }, "packages": { @@ -656,15 +657,13 @@ "@noble/ciphers": ["@noble/ciphers@2.2.0", "", {}, "sha512-Z6pjIZ/8IJcCGzb2S/0Px5J81yij85xASuk1teLNeg75bfT07MV3a/O2Mtn1I2se43k3lkVEcFaR10N4cgQcZA=="], - "@noble/curves": ["@noble/curves@2.2.0", "", { "dependencies": { "@noble/hashes": "2.2.0" } }, "sha512-T/BoHgFXirb0ENSPBquzX0rcjXeM6Lo892a2jlYJkqk83LqZx0l1Of7DzlKJ6jkpvMrkHSnAcgb5JegL8SeIkQ=="], - "@noble/hashes": ["@noble/hashes@2.2.0", "", {}, "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg=="], "@oxc-project/types": ["@oxc-project/types@0.133.0", "", {}, "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA=="], "@parity/truapi": ["@parity/truapi@0.5.1", "", { "dependencies": { "@noble/hashes": "^2.2.0", "neverthrow": "^8.2.0", "scale-ts": "^1.6.1" } }, "sha512-5AV6YoqnUKXj2wJ/qt/J2i3jpFawhEVkY165V5fSDccGkgK2oVHxlSfLwscXpZp4DxwFTL1FmvkhwhuqPDIdtA=="], - "@parity/truapi-host": ["@parity/truapi-host@0.2.0", "", { "dependencies": { "@parity/truapi": "^0.5.0" } }, "sha512-Qmr8+AS0Fvzzw1YlXGDLL24vP5E43HSL0oXNjq/NBD5VWpz0cb/fKukLJf0r6Cs4K8vC5p0DEzIA5hwH40vHbw=="], + "@parity/truapi-host": ["@parity/truapi-host@0.2.1", "", { "dependencies": { "@parity/truapi": "^0.5.1" } }, "sha512-5GoZGaKxXoUaGwt8h3ini0jc+tSrI3Bjme+C0r7CQrPMSbscLp/qxR6aAtVU/EOY5dr8pvXwdzG+5Lafzl/KfQ=="], "@playwright/test": ["@playwright/test@1.60.0", "", { "dependencies": { "playwright": "1.60.0" }, "bin": { "playwright": "cli.js" } }, "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag=="], @@ -716,10 +715,6 @@ "@polkadot-api/ws-provider": ["@polkadot-api/ws-provider@0.9.0", "", { "dependencies": { "@polkadot-api/json-rpc-provider": "0.2.0", "@polkadot-api/json-rpc-provider-proxy": "0.4.0", "@polkadot-api/utils": "0.4.0" }, "peerDependencies": { "rxjs": ">=7.8.0" } }, "sha512-czTLgHEJPqx+6t5sb5OJpXDaSmbDTr8zYngBdUI47QYqcNB225zo/GdYakiNiGThtxVp1MLK7QsNXcT6XgqQNQ=="], - "@polkadot-labs/hdkd": ["@polkadot-labs/hdkd@0.0.28", "", { "dependencies": { "@polkadot-labs/hdkd-helpers": "~0.0.29" } }, "sha512-LpdqtQRpcgZQ5Mr8J0ddMA5ZufsbI4W3KuJkVdoYMnSmWs4179LigDb1rTYAOtyCg2jWUjf7rWP0mGxQQvNrHw=="], - - "@polkadot-labs/hdkd-helpers": ["@polkadot-labs/hdkd-helpers@0.0.30", "", { "dependencies": { "@noble/curves": "^2.2.0", "@noble/hashes": "^2.2.0", "@scure/base": "^2.2.0", "@scure/sr25519": "^1.0.0", "scale-ts": "^1.6.1" } }, "sha512-qWmmD6ayj14RenDuDFfjF3sHS7ObqPzwIIMPcSVoDeKFSeQV7RY0HwyhC5CG4i6FoguMzak2dbtjYpNN5XQiwQ=="], - "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.3", "", { "os": "android", "cpu": "arm64" }, "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw=="], "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA=="], @@ -816,8 +811,6 @@ "@scure/base": ["@scure/base@2.2.0", "", {}, "sha512-b8XEupJibegiXV+tDUseI8oLQc8ei3d/4Jkb2RpbHh3MfE054ov3uIz2dhFkB3FI8iwYkEh0gGCApkrYggkPNg=="], - "@scure/sr25519": ["@scure/sr25519@1.0.0", "", { "dependencies": { "@noble/curves": "~2.0.0", "@noble/hashes": "~2.0.0" } }, "sha512-b+uhK5akMINXZP95F3gJGcb5CMKYxf+q55fwMl0GoBwZDbWolmGNi1FrBSwuaZX5AhqS2byHiAueZgtDNpot2A=="], - "@sec-ant/readable-stream": ["@sec-ant/readable-stream@0.4.1", "", {}, "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg=="], "@sentry-internal/browser-utils": ["@sentry-internal/browser-utils@10.57.0", "", { "dependencies": { "@sentry/core": "10.57.0" } }, "sha512-tXObp954rMTSYKlbftjVXHtNl4t/6ssks3jkqyzmKb+PDPWzabGQO7sWwqVuTjT8Kx/8A3FmriS1bGmqxiJy3A=="], @@ -888,7 +881,7 @@ "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], - "@types/node": ["@types/node@25.9.3", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg=="], + "@types/node": ["@types/node@25.9.5", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-OScDchr2fwuUmWdf4kZ9h7PcJiYDVInhJizG/biAq3cAvqwYktuy/TYGGdZNMtNTFUP7rnb0NU4TUdm82kt4Rg=="], "@types/normalize-package-data": ["@types/normalize-package-data@2.4.4", "", {}, "sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA=="], @@ -972,7 +965,7 @@ "baseline-browser-mapping": ["baseline-browser-mapping@2.10.34", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-IMDedajPifLnHNY0X9n8hKxRTQ6/eTHwr5bDo04WnuqxyKw6LYtQywCuuqPZwhl3aBXMvQpJov42GLCwRRdQzw=="], - "brace-expansion": ["brace-expansion@5.0.7", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA=="], + "brace-expansion": ["brace-expansion@5.0.8", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg=="], "browserslist": ["browserslist@4.28.2", "", { "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", "electron-to-chromium": "^1.5.328", "node-releases": "^2.0.36", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg=="], @@ -1354,7 +1347,7 @@ "nanoevents": ["nanoevents@9.1.0", "", {}, "sha512-Jd0fILWG44a9luj8v5kED4WI+zfkkgwKyRQKItTtlPfEsh7Lznfi1kr8/iZ+XAIss4Qq5GqRB0qtWbaz9ceO/A=="], - "nanoid": ["nanoid@3.3.12", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ=="], + "nanoid": ["nanoid@3.3.16", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q=="], "natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="], @@ -1420,7 +1413,7 @@ "possible-typed-array-names": ["possible-typed-array-names@1.1.0", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="], - "postcss": ["postcss@8.5.15", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A=="], + "postcss": ["postcss@8.5.24", "", { "dependencies": { "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-8RyVklq0owXUTa4xlpzu4l9AaVKIdQvAcOHZWaMh98HgySsUtxRVf/chRe3dsSLqb6i40BzGRzEUddRaI+9TSw=="], "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], @@ -1744,20 +1737,12 @@ "@multiformats/sha3/multiformats": ["multiformats@9.9.0", "", {}, "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg=="], - "@polkadot-api/cli/@types/node": ["@types/node@25.9.5", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-OScDchr2fwuUmWdf4kZ9h7PcJiYDVInhJizG/biAq3cAvqwYktuy/TYGGdZNMtNTFUP7rnb0NU4TUdm82kt4Rg=="], - "@polkadot-api/cli/commander": ["commander@15.0.0", "", {}, "sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg=="], "@polkadot-api/cli/rollup": ["rollup@4.62.2", "", { "dependencies": { "@types/estree": "1.0.9" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.62.2", "@rollup/rollup-android-arm64": "4.62.2", "@rollup/rollup-darwin-arm64": "4.62.2", "@rollup/rollup-darwin-x64": "4.62.2", "@rollup/rollup-freebsd-arm64": "4.62.2", "@rollup/rollup-freebsd-x64": "4.62.2", "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", "@rollup/rollup-linux-arm-musleabihf": "4.62.2", "@rollup/rollup-linux-arm64-gnu": "4.62.2", "@rollup/rollup-linux-arm64-musl": "4.62.2", "@rollup/rollup-linux-loong64-gnu": "4.62.2", "@rollup/rollup-linux-loong64-musl": "4.62.2", "@rollup/rollup-linux-ppc64-gnu": "4.62.2", "@rollup/rollup-linux-ppc64-musl": "4.62.2", "@rollup/rollup-linux-riscv64-gnu": "4.62.2", "@rollup/rollup-linux-riscv64-musl": "4.62.2", "@rollup/rollup-linux-s390x-gnu": "4.62.2", "@rollup/rollup-linux-x64-gnu": "4.62.2", "@rollup/rollup-linux-x64-musl": "4.62.2", "@rollup/rollup-openbsd-x64": "4.62.2", "@rollup/rollup-openharmony-arm64": "4.62.2", "@rollup/rollup-win32-arm64-msvc": "4.62.2", "@rollup/rollup-win32-ia32-msvc": "4.62.2", "@rollup/rollup-win32-x64-gnu": "4.62.2", "@rollup/rollup-win32-x64-msvc": "4.62.2", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA=="], - "@polkadot-api/smoldot/@types/node": ["@types/node@25.9.5", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-OScDchr2fwuUmWdf4kZ9h7PcJiYDVInhJizG/biAq3cAvqwYktuy/TYGGdZNMtNTFUP7rnb0NU4TUdm82kt4Rg=="], - "@rollup/pluginutils/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], - "@scure/sr25519/@noble/curves": ["@noble/curves@2.0.1", "", { "dependencies": { "@noble/hashes": "2.0.1" } }, "sha512-vs1Az2OOTBiP4q0pwjW5aF0xp9n4MxVrmkFBxc6EKZc6ddYx5gaZiAsZoq0uRRXWbi3AT/sBqn05eRPtn1JCPw=="], - - "@scure/sr25519/@noble/hashes": ["@noble/hashes@2.0.1", "", {}, "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw=="], - "@sentry/bundler-plugin-core/dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="], "@sentry/bundler-plugin-core/glob": ["glob@13.0.6", "", { "dependencies": { "minimatch": "^10.2.2", "minipass": "^7.1.3", "path-scurry": "^2.0.2" } }, "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw=="], @@ -1778,6 +1763,8 @@ "get-stream/is-stream": ["is-stream@4.0.1", "", {}, "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A=="], + "happy-dom/@types/node": ["@types/node@25.9.3", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg=="], + "hosted-git-info/lru-cache": ["lru-cache@11.5.1", "", {}, "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A=="], "node-fetch/whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="], @@ -1862,8 +1849,6 @@ "@polkadot-api/cli/rollup/@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.62.2", "", { "os": "win32", "cpu": "x64" }, "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA=="], - "filelist/minimatch/brace-expansion": ["brace-expansion@2.1.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA=="], - "node-fetch/whatwg-url/tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="], "node-fetch/whatwg-url/webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="], @@ -1878,8 +1863,6 @@ "yargs/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="], - "filelist/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], - "ora/string-width/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], "write-package/read-pkg/normalize-package-data/hosted-git-info": ["hosted-git-info@7.0.2", "", { "dependencies": { "lru-cache": "^10.0.1" } }, "sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w=="], diff --git a/package.json b/package.json index 66ca0c8e..77262247 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,8 @@ "@parity/truapi": "0.5.1", "fast-uri": "3.1.4", "smoldot": "3.3.1", - "esbuild": "^0.28.1" + "esbuild": "^0.28.1", + "brace-expansion": "^5.0.8", + "postcss": "^8.5.24" } } diff --git a/packages/protocol/src/auth-storage.ts b/packages/protocol/src/auth-storage.ts index 4adc3ca8..f3863037 100644 --- a/packages/protocol/src/auth-storage.ts +++ b/packages/protocol/src/auth-storage.ts @@ -76,7 +76,13 @@ export function buildSharedAuthStorageKey(siteId: SiteId, key: string): string { } /** Storage key used by the removed Nova host runtime. Its session encoding is - * incompatible with TrUAPI, so the protocol host deletes this key at boot. */ + * incompatible with TrUAPI, so the protocol host deletes this key at boot. + * + * TODO(remove-legacy-nova): unlike the product-facing shim sites sharing this + * tag, this cleanup is gated on returning browsers, not on product migration: + * delete it (with `LEGACY_SHARED_AUTH_SESSION_KEY` above and + * `clearLegacySharedAuthSession` in `apps/protocol/src/main.ts`) once stale + * `PAPP_*` keys in long-lived browser profiles are no longer a concern. */ export function buildLegacySharedAuthSessionStorageKey(siteId: SiteId): string { return `PAPP_${siteId}_${LEGACY_SHARED_AUTH_SESSION_KEY}`; } diff --git a/packages/protocol/src/broker.ts b/packages/protocol/src/broker.ts index 0eb8e776..c450a140 100644 --- a/packages/protocol/src/broker.ts +++ b/packages/protocol/src/broker.ts @@ -53,6 +53,7 @@ interface PendingRequest { interface OwnedToken { sessionId: string; localToken: string; + upstreamToken: string; releaseMethod: string; } @@ -108,8 +109,12 @@ interface BrokerConnection { const TOKEN_METHODS = new Map([ ["transaction_v1_broadcast", "transaction_v1_stop"], + ["transactionWatch_v1_submitAndWatch", "transactionWatch_v1_unwatch"], ["statement_subscribeStatement", "statement_unsubscribeStatement"], ]); +const RELEASE_METHODS = new Set(TOKEN_METHODS.values()); +const MAX_EARLY_SUBSCRIPTION_TOKENS = 32; +const MAX_EARLY_SUBSCRIPTION_EVENTS_PER_TOKEN = 16; function isJsonRpcObject( value: unknown, @@ -189,6 +194,10 @@ function cloneWithRewrittenFirstParam( return { ...request, params }; } +function releaseResultFor(method: string): unknown { + return method === "statement_unsubscribeStatement" ? true : null; +} + export interface ChainBrokerManager { connectRemote( genesisHash: string, @@ -227,7 +236,11 @@ class ChainBroker { private readonly sessions = new Map(); private readonly pending = new Map(); private readonly localToOwned = new Map(); - private readonly upstreamToOwned = new Map(); + private readonly upstreamToOwned = new Map>(); + private readonly earlySubscriptions = new Map< + string, + SubscriptionMessage[] + >(); private readonly localFollowTokens = new Map< string, { sessionId: string; followKey: string } @@ -375,10 +388,16 @@ class ChainBroker { /** Rewrite a session-owned token to its upstream token and forward. */ private routeGenericRequest(session: Session, request: JsonRpcRequest): void { + const method = request.method as string; + if (RELEASE_METHODS.has(method)) { + this.routeOwnedReleaseRequest(session, request, method); + return; + } + const rewritten = this.rewriteOwnedToken(session, request); if (rewritten === null) { brokerLog( - `routeGenericRequest: unknown token for session ${session.id}, method=${request.method as string}`, + `routeGenericRequest: unknown token for session ${session.id}, method=${method}`, ); this.sendToSession( session, @@ -397,7 +416,64 @@ class ChainBroker { this.pending.set(upstreamId, { sessionId: session.id, clientId: request.id ?? null, - method: request.method as string, + method, + }); + this.sendUpstream({ ...rewritten, id: upstreamId }); + } + + private routeOwnedReleaseRequest( + session: Session, + request: JsonRpcRequest, + method: string, + ): void { + const params = Array.isArray(request.params) ? request.params : []; + const localToken = typeof params[0] === "string" ? params[0] : null; + const owned = + localToken !== null ? this.localToOwned.get(localToken) : undefined; + if ( + localToken === null || + owned?.sessionId !== session.id || + owned.releaseMethod !== method + ) { + this.sendToSession( + session, + buildJsonRpcError(request.id ?? null, "Unknown subscription/token"), + ); + return; + } + + const upstreamToken = owned.upstreamToken; + const released = this.releaseOwnedToken(localToken, false); + if (released === null) { + this.sendToSession( + session, + buildJsonRpcError(request.id ?? null, "Unknown subscription/token"), + ); + return; + } + + if (!released.lastOwner) { + if (request.id !== undefined) { + this.sendToSession( + session, + buildJsonRpcResult(request.id ?? null, releaseResultFor(method)), + ); + } + return; + } + + const rewritten = cloneWithRewrittenFirstParam(request, upstreamToken); + if (request.id === undefined) { + this.sendUpstream(rewritten); + return; + } + + const upstreamId = `broker:${this.requestCounter.toString(36)}:${session.id}`; + this.requestCounter += 1; + this.pending.set(upstreamId, { + sessionId: session.id, + clientId: request.id ?? null, + method, }); this.sendUpstream({ ...rewritten, id: upstreamId }); } @@ -569,21 +645,7 @@ class ChainBroker { return null; } - const upstreamToken = this.getUpstreamToken(firstParam); - if (upstreamToken === null) { - return null; - } - - return cloneWithRewrittenFirstParam(request, upstreamToken); - } - - private getUpstreamToken(localToken: string): string | null { - for (const [upstreamToken, owned] of this.upstreamToOwned.entries()) { - if (owned.localToken === localToken) { - return upstreamToken; - } - } - return null; + return cloneWithRewrittenFirstParam(request, owned.upstreamToken); } private handleUpstreamMessage(message: unknown): void { @@ -641,9 +703,16 @@ class ChainBroker { const event = result.event; const rawSub = parsed.params?.subscription; const token = typeof rawSub === "string" ? rawSub : "?"; - // Find which session owns this token - const owned = this.upstreamToOwned.get(token); - const sessionTag = owned ? owned.sessionId : "unknown"; + const ownedLocals = this.upstreamToOwned.get(token); + const sessionTag = + ownedLocals !== undefined && ownedLocals.size > 0 + ? [...ownedLocals] + .map( + (localToken) => + this.localToOwned.get(localToken)?.sessionId ?? "?", + ) + .join(",") + : "unknown"; if (event === "newBlock") { brokerLog( `← raw newBlock [${sessionTag}] hash=${String(result.blockHash).slice(0, 18)}… parent=${String(result.parentBlockHash).slice(0, 18)}… token=${token.slice(0, 12)}…`, @@ -716,6 +785,7 @@ class ChainBroker { buildJsonRpcResult(pendingLocal.requestId, pendingLocal.localToken), ); } + this.flushEarlySubscriptions(response.result); return; } @@ -735,10 +805,16 @@ class ChainBroker { const owned: OwnedToken = { sessionId: pending.sessionId, localToken, + upstreamToken: response.result, releaseMethod, }; this.localToOwned.set(localToken, owned); - this.upstreamToOwned.set(response.result, owned); + let localTokens = this.upstreamToOwned.get(response.result); + if (localTokens === undefined) { + localTokens = new Set(); + this.upstreamToOwned.set(response.result, localTokens); + } + localTokens.add(localToken); session.ownedTokens.add(localToken); brokerLog( `Token mapped: ${localToken} ↔ ${response.result} (${pending.method})`, @@ -754,6 +830,9 @@ class ChainBroker { rewritten.result = result; } this.sendToSession(session, rewritten); + if (releaseMethod !== undefined && typeof response.result === "string") { + this.flushEarlySubscriptions(response.result); + } } private handleUpstreamSubscription(message: SubscriptionMessage): void { @@ -818,41 +897,102 @@ class ChainBroker { return; } - const owned = this.upstreamToOwned.get(upstreamToken); - if (!owned) { + const ownedLocals = this.upstreamToOwned.get(upstreamToken); + if (ownedLocals === undefined || ownedLocals.size === 0) { + if (this.hasPendingSubscriptionRequest()) { + this.bufferEarlySubscription(upstreamToken, message); + return; + } brokerLog(`← upstream subscription for unknown token: ${upstreamToken}`); return; } - const session = this.sessions.get(owned.sessionId); - if (session?.connected !== true) { - brokerLog( - `← upstream subscription for disconnected session: ${owned.sessionId}`, - ); - return; - } - const eventResult = message.params?.result; const eventType = isJsonRpcObject(eventResult) ? typeof eventResult.event === "string" ? eventResult.event : "unknown" : "?"; - brokerLog( - `← subscription [${owned.sessionId}] event=${eventType} method=${String(message.method)}`, - ); + const localTokens = [...ownedLocals]; + for (const localToken of localTokens) { + const owned = this.localToOwned.get(localToken); + if (owned === undefined) { + continue; + } - this.sendToSession(session, { - ...message, - params: { - ...message.params, - subscription: owned.localToken, - }, - }); + const session = this.sessions.get(owned.sessionId); + if (session?.connected !== true) { + brokerLog( + `← upstream subscription for disconnected session: ${owned.sessionId}`, + ); + continue; + } + + brokerLog( + `← subscription [${owned.sessionId}] event=${eventType} method=${String(message.method)}`, + ); + + this.sendToSession(session, { + ...message, + params: { + ...message.params, + subscription: owned.localToken, + }, + }); + } if (isJsonRpcObject(eventResult) && eventResult.event === "stop") { - brokerLog(`Token stopped by upstream: ${owned.localToken}`); - this.releaseOwnedToken(owned.localToken, false); + brokerLog(`Token stopped by upstream: ${upstreamToken}`); + for (const localToken of localTokens) { + this.releaseOwnedToken(localToken, false); + } + } + } + + private hasPendingSubscriptionRequest(): boolean { + return [...this.pending.values()].some( + ({ method }) => + method === "chainHead_v1_follow" || TOKEN_METHODS.has(method), + ); + } + + private bufferEarlySubscription( + upstreamToken: string, + message: SubscriptionMessage, + ): void { + let events = this.earlySubscriptions.get(upstreamToken); + if (events === undefined) { + if (this.earlySubscriptions.size >= MAX_EARLY_SUBSCRIPTION_TOKENS) { + const oldestToken = this.earlySubscriptions.keys().next().value; + if (oldestToken !== undefined) { + this.earlySubscriptions.delete(oldestToken); + brokerLog( + `early-subscription token cap hit; dropping buffered events for oldest token: ${oldestToken}`, + ); + } + } + events = []; + this.earlySubscriptions.set(upstreamToken, events); + } + if (events.length < MAX_EARLY_SUBSCRIPTION_EVENTS_PER_TOKEN) { + events.push(message); + } else { + // Memory bound, not correctness: events for a token that never maps + // to a local subscription would otherwise grow without limit. + brokerLog( + `early-subscription event cap hit; dropping event for token: ${upstreamToken}`, + ); + } + } + + private flushEarlySubscriptions(upstreamToken: string): void { + const events = this.earlySubscriptions.get(upstreamToken); + if (events === undefined) { + return; + } + this.earlySubscriptions.delete(upstreamToken); + for (const event of events) { + this.handleUpstreamSubscription(event); } } @@ -889,46 +1029,49 @@ class ChainBroker { } } - private releaseOwnedToken(localToken: string, notifyUpstream: boolean): void { + private releaseOwnedToken( + localToken: string, + notifyUpstream: boolean, + ): { lastOwner: boolean } | null { const owned = this.localToOwned.get(localToken); if (!owned) { - return; + return null; } this.localToOwned.delete(localToken); const session = this.sessions.get(owned.sessionId); session?.ownedTokens.delete(localToken); - let upstreamTokenToDelete: string | null = null; - for (const [upstreamToken, candidate] of this.upstreamToOwned.entries()) { - if (candidate.localToken === localToken) { - upstreamTokenToDelete = upstreamToken; - break; - } + const localTokens = this.upstreamToOwned.get(owned.upstreamToken); + if (localTokens?.delete(localToken) !== true) { + return null; } - if (upstreamTokenToDelete === null) { - return; + + if (localTokens.size > 0) { + return { lastOwner: false }; } - this.upstreamToOwned.delete(upstreamTokenToDelete); + this.upstreamToOwned.delete(owned.upstreamToken); if (!notifyUpstream) { - return; + return { lastOwner: true }; } this.sendUpstream({ jsonrpc: "2.0", id: `broker-release:${this.requestCounter.toString(36)}`, method: owned.releaseMethod, - params: [upstreamTokenToDelete], + params: [owned.upstreamToken], }); this.requestCounter += 1; + return { lastOwner: true }; } private disconnectUpstream(): void { this.pending.clear(); this.localToOwned.clear(); this.upstreamToOwned.clear(); + this.earlySubscriptions.clear(); this.localFollowTokens.clear(); this.sharedFollows.clear(); this.upstreamFollowTokens.clear(); diff --git a/packages/protocol/src/client.ts b/packages/protocol/src/client.ts index b6df73f8..de3baa14 100644 --- a/packages/protocol/src/client.ts +++ b/packages/protocol/src/client.ts @@ -737,6 +737,14 @@ export function createRemoteChainProvider( void postRequest("chainSend", { connectionId, message: JSON.stringify(message), + }).catch((error: unknown) => { + const errResponse = buildJsonRpcError( + message, + serializeError(error), + ); + if (errResponse !== null) { + onMessage(errResponse); + } }); } remote.pendingMessages = []; diff --git a/packages/protocol/tests/broker.test.ts b/packages/protocol/tests/broker.test.ts index 010bf8b6..788c375f 100644 --- a/packages/protocol/tests/broker.test.ts +++ b/packages/protocol/tests/broker.test.ts @@ -201,6 +201,282 @@ describe("createChainBrokerManager", () => { expect(harness.disconnect).toHaveBeenCalledTimes(1); }); + it("releases transactionWatch subscriptions on disconnect", () => { + const harness = createProviderHarness(); + const manager = createChainBrokerManager(() => harness.provider); + const connection = manager.connectRemote("asset-hub", "conn-a", () => {}); + + connection?.send( + JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "transactionWatch_v1_submitAndWatch", + params: ["0x0102"], + }), + ); + + const upstreamRequest = harness.sent[0] as { id: string; method: string }; + expect(upstreamRequest.method).toBe("transactionWatch_v1_submitAndWatch"); + harness.emit({ jsonrpc: "2.0", id: upstreamRequest.id, result: "up-tx" }); + + connection?.disconnect(); + + const release = harness.sent[1] as { method: string; params: string[] }; + expect(release.method).toBe("transactionWatch_v1_unwatch"); + expect(release.params[0]).toBe("up-tx"); + }); + + it("delivers transactionWatch events received before the subscribe response", () => { + const harness = createProviderHarness(); + const manager = createChainBrokerManager(() => harness.provider); + const messages: string[] = []; + const connection = manager.connectRemote("asset-hub", "conn-a", (message) => + messages.push(message), + ); + + connection?.send( + JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "transactionWatch_v1_submitAndWatch", + params: ["0x0102"], + }), + ); + + const upstreamRequest = harness.sent[0] as { id: string }; + harness.emit({ + jsonrpc: "2.0", + method: "transactionWatch_v1_watchEvent", + params: { + subscription: "up-tx", + result: { event: "finalized", block: { hash: "0xabc" } }, + }, + }); + expect(messages).toEqual([]); + + harness.emit({ + jsonrpc: "2.0", + id: upstreamRequest.id, + result: "up-tx", + }); + + const response = JSON.parse(messages[0] ?? "{}") as { result: string }; + expect(JSON.parse(messages[1] ?? "{}")).toEqual({ + jsonrpc: "2.0", + method: "transactionWatch_v1_watchEvent", + params: { + subscription: response.result, + result: { event: "finalized", block: { hash: "0xabc" } }, + }, + }); + }); + + it("fans out same-token statement notifications to every local owner", () => { + const harness = createProviderHarness(); + const manager = createChainBrokerManager(() => harness.provider); + const messagesA: string[] = []; + const messagesB: string[] = []; + const connectionA = manager.connectRemote("asset-hub", "conn-a", (m) => + messagesA.push(m), + ); + const connectionB = manager.connectRemote("asset-hub", "conn-b", (m) => + messagesB.push(m), + ); + + connectionA?.send( + JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "statement_subscribeStatement", + params: [{ matchAll: ["0xtopic"] }], + }), + ); + connectionB?.send( + JSON.stringify({ + jsonrpc: "2.0", + id: 2, + method: "statement_subscribeStatement", + params: [{ matchAll: ["0xtopic"] }], + }), + ); + + harness.emit({ + jsonrpc: "2.0", + id: (harness.sent[0] as { id: string }).id, + result: "up-stmt", + }); + harness.emit({ + jsonrpc: "2.0", + id: (harness.sent[1] as { id: string }).id, + result: "up-stmt", + }); + + const localTokenA = (JSON.parse(messagesA[0] ?? "{}") as { result: string }) + .result; + const localTokenB = (JSON.parse(messagesB[0] ?? "{}") as { result: string }) + .result; + expect(localTokenA).not.toBe(localTokenB); + + harness.emit({ + jsonrpc: "2.0", + method: "statement_statement", + params: { + subscription: "up-stmt", + result: { event: "newStatements", data: { statements: [] } }, + }, + }); + + expect(messagesA[1]).toBe( + JSON.stringify({ + jsonrpc: "2.0", + method: "statement_statement", + params: { + subscription: localTokenA, + result: { event: "newStatements", data: { statements: [] } }, + }, + }), + ); + expect(messagesB[1]).toBe( + JSON.stringify({ + jsonrpc: "2.0", + method: "statement_statement", + params: { + subscription: localTokenB, + result: { event: "newStatements", data: { statements: [] } }, + }, + }), + ); + + connectionA?.disconnect(); + expect( + harness.sent.filter( + (message) => + (message as JsonRpcRequest).method === + "statement_unsubscribeStatement", + ), + ).toHaveLength(0); + + connectionB?.disconnect(); + const releases = harness.sent.filter( + (message) => + (message as JsonRpcRequest).method === "statement_unsubscribeStatement", + ); + expect(releases).toHaveLength(1); + expect((releases[0]?.params as unknown[])[0]).toBe("up-stmt"); + }); + + it("ref-counts same-token statement unsubscribe requests", () => { + const harness = createProviderHarness(); + const manager = createChainBrokerManager(() => harness.provider); + const messagesA: string[] = []; + const messagesB: string[] = []; + const connectionA = manager.connectRemote("asset-hub", "conn-a", (m) => + messagesA.push(m), + ); + const connectionB = manager.connectRemote("asset-hub", "conn-b", (m) => + messagesB.push(m), + ); + + connectionA?.send( + JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "statement_subscribeStatement", + params: [{ matchAll: ["0xtopic"] }], + }), + ); + connectionB?.send( + JSON.stringify({ + jsonrpc: "2.0", + id: 2, + method: "statement_subscribeStatement", + params: [{ matchAll: ["0xtopic"] }], + }), + ); + harness.emit({ + jsonrpc: "2.0", + id: (harness.sent[0] as { id: string }).id, + result: "up-stmt", + }); + harness.emit({ + jsonrpc: "2.0", + id: (harness.sent[1] as { id: string }).id, + result: "up-stmt", + }); + const localTokenA = (JSON.parse(messagesA[0] ?? "{}") as { result: string }) + .result; + const localTokenB = (JSON.parse(messagesB[0] ?? "{}") as { result: string }) + .result; + + connectionA?.send( + JSON.stringify({ + jsonrpc: "2.0", + id: 10, + method: "statement_unsubscribeStatement", + params: [localTokenA], + }), + ); + expect(JSON.parse(messagesA.at(-1) ?? "{}")).toEqual({ + jsonrpc: "2.0", + id: 10, + result: true, + }); + expect( + harness.sent.filter( + (message) => + (message as JsonRpcRequest).method === + "statement_unsubscribeStatement", + ), + ).toHaveLength(0); + + harness.emit({ + jsonrpc: "2.0", + method: "statement_statement", + params: { + subscription: "up-stmt", + result: { event: "newStatements", data: { statements: [] } }, + }, + }); + expect(messagesA).toHaveLength(2); + expect(messagesB.at(-1)).toBe( + JSON.stringify({ + jsonrpc: "2.0", + method: "statement_statement", + params: { + subscription: localTokenB, + result: { event: "newStatements", data: { statements: [] } }, + }, + }), + ); + + connectionB?.send( + JSON.stringify({ + jsonrpc: "2.0", + id: 11, + method: "statement_unsubscribeStatement", + params: [localTokenB], + }), + ); + const release = harness.sent.at(-1) as { + id: string; + method: string; + params: string[]; + }; + expect(release.method).toBe("statement_unsubscribeStatement"); + expect(release.params[0]).toBe("up-stmt"); + + harness.emit({ + jsonrpc: "2.0", + id: release.id, + result: true, + } as JsonRpcMessage); + expect(JSON.parse(messagesB.at(-1) ?? "{}")).toEqual({ + jsonrpc: "2.0", + id: 11, + result: true, + }); + }); + it("reuses the warm upstream when a new session attaches after every previous one disconnected", () => { const harness = createProviderHarness(); const manager = createChainBrokerManager(() => harness.provider); diff --git a/packages/resolver/package.json b/packages/resolver/package.json index 8b12f519..f6930a67 100644 --- a/packages/resolver/package.json +++ b/packages/resolver/package.json @@ -31,12 +31,9 @@ "@ensdomains/content-hash": "^3.0.0", "@polkadot-api/json-rpc-provider": "^0.2.0", "@polkadot-api/json-rpc-provider-proxy": "^0.4.0", - "@polkadot-api/signer": "^0.3.3", "@polkadot-api/substrate-bindings": "0.20.3", "@polkadot-api/substrate-client": "0.7.0", "@polkadot-api/utils": "0.4.0", - "@polkadot-labs/hdkd": "^0.0.28", - "@polkadot-labs/hdkd-helpers": "^0.0.30", "polkadot-api": "^2.1.8", "smoldot": "^3.1.4" } diff --git a/packages/resolver/tests/chains.test.ts b/packages/resolver/tests/chains.test.ts new file mode 100644 index 00000000..1355544c --- /dev/null +++ b/packages/resolver/tests/chains.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from "vitest"; +import { getActiveSupportedGenesisHashes } from "@dotli/config/network"; +import { isChainSupported } from "@dotli/resolver/chains"; + +describe("isChainSupported", () => { + it("accepts every chain the host runtime can expose", () => { + for (const genesisHash of getActiveSupportedGenesisHashes()) { + expect(isChainSupported(genesisHash)).toBe(true); + } + }); + + it("rejects unknown genesis hashes", () => { + expect(isChainSupported("0xdeadbeef")).toBe(false); + }); +}); diff --git a/packages/resolver/tests/rpc-chain.test.ts b/packages/resolver/tests/rpc-chain.test.ts index bb0278e7..e61b35df 100644 --- a/packages/resolver/tests/rpc-chain.test.ts +++ b/packages/resolver/tests/rpc-chain.test.ts @@ -16,6 +16,25 @@ vi.mock("polkadot-api/ws", () => ({ })); describe("rpc-chain", () => { + it("supports the active People chain when RPC endpoints are configured", () => { + const people = getActiveServicesConfig().people; + + expect(isRpcChainSupported(people.genesis)).toBe(true); + + const provider = {}; + mocks.getWsProvider.mockReturnValueOnce(provider); + + expect(createRpcChainProvider(people.genesis)).toBe(provider); + expect(mocks.getWsProvider).toHaveBeenCalledWith([...people.rpcs], { + heartbeatTimeout: 120_000, + }); + }); + + it("rejects unknown genesis hashes", () => { + expect(isRpcChainSupported("0xdeadbeef")).toBe(false); + expect(createRpcChainProvider("0xdeadbeef")).toBeNull(); + }); + it("As a dotli integrator, the host reserves Bulletin RPC access for the host-owned Rust core", () => { // Given const bulletin = getActiveServicesConfig().bulletin; diff --git a/packages/truapi-debug/package.json b/packages/truapi-debug/package.json index be0b89c0..5b623416 100644 --- a/packages/truapi-debug/package.json +++ b/packages/truapi-debug/package.json @@ -19,6 +19,7 @@ "vite": "^8.0.16" }, "dependencies": { + "@dotli/config": "workspace:*", "@dotli/shared": "workspace:*", "nanoevents": "^9.1.0" } diff --git a/packages/truapi-debug/src/panel.ts b/packages/truapi-debug/src/panel.ts index be304773..687ab4d6 100644 --- a/packages/truapi-debug/src/panel.ts +++ b/packages/truapi-debug/src/panel.ts @@ -194,7 +194,7 @@ function adjustIframeForPanel(panel: HTMLElement, state: PanelState): void { return; } const hasTopbar = document.getElementById("topbar") !== null; - const topOffset = hasTopbar ? 40 : 0; + const topOffset = hasTopbar ? 56 : 0; if (state.dock === "right") { iframe.style.height = `calc(100vh - ${String(topOffset)}px)`; // When collapsed, the 32px header bar overlays the top-right corner diff --git a/packages/truapi-debug/src/timeline-layout.ts b/packages/truapi-debug/src/timeline-layout.ts index 5e9881be..f3533a1e 100644 --- a/packages/truapi-debug/src/timeline-layout.ts +++ b/packages/truapi-debug/src/timeline-layout.ts @@ -652,7 +652,7 @@ function segmentForGroup( * point-in-time system events (boot phases, failover decisions, etc.) * still occupy a lane position at the right Y. `pending` is true for * flows whose first event is a "start" kind without a matching end - * event in the buffer. See SYSTEM_TERMINATOR_SUFFIXES. + * event in the buffer. See `isSystemFlowTerminator`. */ function systemSegmentForGroup( group: StoredSystemEvent[], @@ -688,22 +688,21 @@ function systemSegmentDetail( return `${first.layer}·${String(all.length)} step${all.length === 1 ? "" : "s"}`; } -/** Events that close a multi-step system flow. Mirrors the pairs we - * document in the detail pane so visual pending state matches intent. */ +/** Exact layer:event names that close a multi-step system flow. */ +const SYSTEM_TERMINATOR_EVENTS: ReadonlySet = new Set([ + "boot:ready", + "boot:landing_page_shown", + "bridge:first_outbound", + "render:iframe_ready", + "sandbox:document_written", + "main:monitor_stopped", +]); + +/** Suffixes that close error/completion families without listing every event. */ const SYSTEM_TERMINATOR_SUFFIXES: readonly string[] = [ - "ready", "failed", - "landing_page_shown", "completed", "terminated", - "iframe_ready", - // `setup_ready` does not close the bridge flow. It only means the - // host is listening, and the product can stay silent for many seconds - // after that (iframe still loading, sandbox relay not ready). The - // bridge flow stays open until bidirectional traffic is observed via - // `first_outbound`. - "first_outbound", - "document_written", "peer_action_processed", "peer_action_failed", "host_action_response_received", @@ -712,10 +711,13 @@ const SYSTEM_TERMINATOR_SUFFIXES: readonly string[] = [ "resolve_failed", ]; -function isSystemFlowTerminator(ev: StoredSystemEvent): boolean { +export function isSystemFlowTerminator(ev: StoredSystemEvent): boolean { const event = ev.event; + if (SYSTEM_TERMINATOR_EVENTS.has(`${ev.layer}:${event}`)) { + return true; + } return SYSTEM_TERMINATOR_SUFFIXES.some( - (suf) => event === suf || event.endsWith(`_${suf}`) || event === suf, + (suf) => event === suf || event.endsWith(`_${suf}`), ); } diff --git a/packages/ui/package.json b/packages/ui/package.json index 91b2fe08..f8a728b1 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -17,6 +17,7 @@ "@dotli/typescript-config": "workspace:*", "@types/qrcode": "^1.5.6", "eslint": "^10.5.0", + "fake-indexeddb": "^6.2.5", "happy-dom": "^20.10.3", "typescript": "~6.0.3", "vite": "^8.0.16", @@ -33,7 +34,7 @@ "@dotli/truapi-debug": "workspace:*", "@noble/hashes": "^2.2.0", "@parity/truapi": "0.5.1", - "@parity/truapi-host": "0.2.0", + "@parity/truapi-host": "0.2.1", "@polkadot-api/json-rpc-provider": "^0.2.0", "@scure/base": "^2.2.0", "neverthrow": "^8.2.0", diff --git a/packages/ui/src/bridge.ts b/packages/ui/src/bridge.ts index 1b5b911c..f4dc800a 100644 --- a/packages/ui/src/bridge.ts +++ b/packages/ui/src/bridge.ts @@ -31,7 +31,10 @@ import { getNetwork } from "@dotli/config/network"; import { m } from "@dotli/metrics/metrics"; import * as S from "@dotli/metrics/spans"; import { log } from "@dotli/shared/log"; -import { emitDotliDebugEvent } from "@dotli/truapi-debug/dotli-debug-bus"; +import { + emitDotliDebugEvent, + hasDotliDebugListeners, +} from "@dotli/truapi-debug/dotli-debug-bus"; import type { TrUApiProductProvider } from "@parity/truapi-host"; import type { PairingHostAdmin } from "@parity/truapi-host"; import { @@ -43,6 +46,7 @@ import { dispatchAuthState } from "./host-callbacks/AuthState"; import { onStoredSessionChanged } from "./host-callbacks/SessionStore"; import { LoginRequestError } from "./login-request-error"; import { createTruapiRuntimeConfig, labelToProductId } from "./runtime-config"; +// TODO(remove-legacy-nova): import used only by the legacy probe tagged below. import { createLegacyNovaChainHeadProvider, createWindowMessageProvider, @@ -50,8 +54,6 @@ import { import type { BlockingModalCoordinator } from "./blocking-modal-queue"; import { showNotification } from "./notification"; -// DEPRECATED: enables the legacy Nova host-api transport shim for products that -// have not yet migrated to `@parity/truapi`. Remove once they have. const noop = (): void => undefined; // Eagerly load the iframe host chunk + worker constructor so they're ready @@ -473,6 +475,91 @@ function pipeProviders( }; } +function emitWireFrameDebug( + direction: "incoming" | "outgoing", + productId: string, + message: Uint8Array, +): void { + if (!hasDotliDebugListeners()) { + return; + } + const decoded = decodeWireMessage(message); + if (decoded.isErr()) { + return; + } + const wireId = decoded.value.payload.id; + emitDotliDebugEvent({ + kind: "truapi", + direction, + productId, + requestId: decoded.value.requestId, + payload: { + tag: `wire_${String(wireId)}`, + value: { + wireId, + bytes: decoded.value.payload.value, + }, + }, + }); +} + +function wrapCoreProviderForDebug( + provider: CoreProviderBase, + productId: string, +): CoreProviderBase { + const listeners = new Set<(message: Uint8Array) => void>(); + let disposed = false; + const unsubscribeCore = provider.subscribe((message) => { + if (disposed) { + return; + } + emitWireFrameDebug("outgoing", productId, message); + for (const listener of [...listeners]) { + listener(message); + } + }); + + return { + postMessage(message: Uint8Array): void { + if (disposed) { + return; + } + emitWireFrameDebug("incoming", productId, message); + provider.postMessage(message); + }, + subscribe(callback) { + listeners.add(callback); + return () => { + listeners.delete(callback); + }; + }, + subscribeClose(callback) { + return provider.subscribeClose?.(callback) ?? noop; + }, + async disconnectSession() { + await provider.disconnectSession(); + }, + getPermissionAuthorizationStatus(request) { + return provider.getPermissionAuthorizationStatus(request); + }, + getPermissionAuthorizationStatuses(requests) { + return provider.getPermissionAuthorizationStatuses(requests); + }, + setPermissionAuthorizationStatus(request, status) { + return provider.setPermissionAuthorizationStatus(request, status); + }, + dispose() { + if (disposed) { + return; + } + disposed = true; + unsubscribeCore(); + listeners.clear(); + provider.dispose(); + }, + }; +} + let topbarLoginRequestSeq = 0; export function requestCoreLogin( @@ -613,6 +700,9 @@ async function createHost(args: { const productId = args.productId ?? labelToProductId(args.label); let productProvider: Provider | null = null; let disposePipe: (() => void) | null = null; + // TODO(remove-legacy-nova): `legacyProbeCleanup` (including its two `?.()` + // call sites in `dispose()` and the catch block below) exists only for the + // legacy probe block tagged further down. let legacyProbeCleanup: (() => void) | null = null; const pipeArgs = { flowId: args.debugFlowId, @@ -643,7 +733,15 @@ async function createHost(args: { // with `{type:"truapi-ready"}` and use the MessagePort wired above. Products // still on the Nova host-api SDK instead post raw SCALE frames (Uint8Array) // to `window.parent`. Detect that first frame and re-pipe the core over a - // window-postMessage provider. Remove once products migrate. + // window-postMessage provider. + // + // TODO(remove-legacy-nova): once the last legacy Nova product migrates to + // `@parity/truapi`, delete this probe block (through the + // `legacyProbeCleanup` assignment below), the `legacyProbeCleanup` + // declaration and call sites tagged above, the `legacy-host-bridge` + // import at the top of this file, and the tagged `legacy-host-bridge.ts` + // module itself. Modern products need no probe: the MessagePort from + // `onPort` is the only wiring. let probeMode: "pending" | "modern" | "legacy" = "pending"; const onProbe = (event: MessageEvent): void => { if (probeMode !== "pending") { @@ -733,11 +831,7 @@ async function createCoreProvider( try { const { createWebWorkerPairingHostRuntime, HostWorker } = await runtimeChunkPromise; - const runtimeConfig = createTruapiRuntimeConfig( - label, - window.location, - options.productId, - ); + const runtimeConfig = createTruapiRuntimeConfig(label, options.productId); const { productId, ...hostConfig } = runtimeConfig; const runtime = await createWebWorkerPairingHostRuntime( new HostWorker(), @@ -753,9 +847,13 @@ async function createCoreProvider( }, ); const provider = await runtime.createProvider({ productId }); - return trackCoreProvider(provider, runtime, () => { - blockingModalScope.dispose(); - }); + return trackCoreProvider( + wrapCoreProviderForDebug(provider, options.productId ?? label), + runtime, + () => { + blockingModalScope.dispose(); + }, + ); } catch (error) { blockingModalScope.dispose(); throw error; diff --git a/packages/ui/src/bulletin-bitswap.ts b/packages/ui/src/bulletin-bitswap.ts index 06dca7cc..143d8e0b 100644 --- a/packages/ui/src/bulletin-bitswap.ts +++ b/packages/ui/src/bulletin-bitswap.ts @@ -12,6 +12,7 @@ import { isRemoteChainSupported, } from "@dotli/protocol/client"; import { isSandboxOrigin } from "@dotli/config/config"; +import { getBackend } from "@dotli/config/mode"; import { getActiveServicesConfig } from "@dotli/config/network"; import { log } from "@dotli/shared/log"; import { serializeError } from "@dotli/shared/errors"; @@ -200,7 +201,13 @@ function isBitswapGetMessage(value: unknown): value is BitswapGetMessage { /** Idempotent. Call once at host startup. */ export function listenForSandboxBitswap(): void { - if (!isRemoteChainSupported(getActiveServicesConfig().bulletin.genesis)) { + if (getBackend() === "rpc-gateway") { + log.warn( + "[dot.li bitswap-relay] Bitswap is unavailable in RPC gateway mode; sandbox bitswap requests will fail.", + ); + } else if ( + !isRemoteChainSupported(getActiveServicesConfig().bulletin.genesis) + ) { log.warn( "[dot.li bitswap-relay] Bulletin not in supported chain set; sandbox bitswap requests will fail.", ); diff --git a/packages/ui/src/host-callbacks/Preimage.ts b/packages/ui/src/host-callbacks/Preimage.ts index f88e01ed..b7593254 100644 --- a/packages/ui/src/host-callbacks/Preimage.ts +++ b/packages/ui/src/host-callbacks/Preimage.ts @@ -30,6 +30,10 @@ function createPreimageLookupSubscribe( const cached = preimageCache.get(key); if (cached) { + // Emits the hit and then stays open without ever completing, same as + // the polling path after it finds a value. That matches the core's + // contract: it consumes lookupPreimage as a long-lived subscription + // and cancels it from the product side, never waiting for `done`. return createResultStream([cached], () => noop); } @@ -58,6 +62,7 @@ function createPreimageLookupSubscribe( const cached = preimageCache.get(key); if (cached) { push(cached); + stopPolling(); return; } diff --git a/packages/ui/src/host-callbacks/PromptPermission.ts b/packages/ui/src/host-callbacks/PromptPermission.ts index e3abaa55..7f1f3102 100644 --- a/packages/ui/src/host-callbacks/PromptPermission.ts +++ b/packages/ui/src/host-callbacks/PromptPermission.ts @@ -19,6 +19,7 @@ import { throwIfAborted, type BlockingModalScope, } from "../blocking-modal-queue"; +import { createSubmitRateLimiter, type SubmitRateLimiter } from "./rate-limit"; // Remote tags that don't reach a host enforcement point: WebRtc is gated // by the iframe `allow` attribute, and `Remote` (HTTP/WS) can't be @@ -40,6 +41,10 @@ function gatedRemotePermissionName( export function createPromptPermission( label: string, modalScope: BlockingModalScope = createBlockingModalScope(), + // One budget per host callback surface: `handlers.ts` passes the same + // limiter here and to the notification adapters so a product cannot double + // its prompt budget by alternating prompt kinds. + limiter: SubmitRateLimiter = createSubmitRateLimiter(), ): Permissions { const devicePermission: Permissions["devicePermission"] = async (tag) => { // OpenUrl has no host-side enforcement point; auto-grant rather than show @@ -53,6 +58,7 @@ export function createPromptPermission( tag, { kind: "Device", + limiter, reloadOnGrant: isDevicePermission(tag), }, modalScope, @@ -71,6 +77,7 @@ export function createPromptPermission( name, { kind: "Remote", + limiter, }, modalScope, ), @@ -85,6 +92,7 @@ export async function decidePromptPermission( name: EnforceablePermissionName, options: { kind: "Device" | "Remote"; + limiter: { allow: () => boolean }; reloadOnGrant?: boolean; }, modalScope: BlockingModalScope = createBlockingModalScope(), @@ -99,11 +107,12 @@ async function decidePromptPermissionWhenActive( name: EnforceablePermissionName, options: { kind: "Device" | "Remote"; + limiter: { allow: () => boolean }; reloadOnGrant?: boolean; }, signal: AbortSignal, ): Promise { - const { kind, reloadOnGrant = false } = options; + const { kind, limiter, reloadOnGrant = false } = options; const status = await getPermissionStatus(label, name); throwIfAborted(signal); if (status === "granted") { @@ -122,6 +131,9 @@ async function decidePromptPermissionWhenActive( return false; } // status === "ask": show the modal and wait for the user. + if (!limiter.allow()) { + throw new Error("Permission prompt rate limited"); + } const decision = await showPermissionRequestModal(label, name, signal); throwIfAborted(signal); if (decision === "dismissed") { diff --git a/packages/ui/src/host-callbacks/PushNotification.ts b/packages/ui/src/host-callbacks/PushNotification.ts index 029424da..447da457 100644 --- a/packages/ui/src/host-callbacks/PushNotification.ts +++ b/packages/ui/src/host-callbacks/PushNotification.ts @@ -13,10 +13,15 @@ import { createBlockingModalScope, type BlockingModalScope, } from "../blocking-modal-queue"; +import { createSubmitRateLimiter, type SubmitRateLimiter } from "./rate-limit"; export function createNotificationAdapters( label: string, modalScope: BlockingModalScope = createBlockingModalScope(), + // One budget per host callback surface: `handlers.ts` passes the same + // limiter here and to the permission prompts so a product cannot double + // its prompt budget by alternating prompt kinds. + limiter: SubmitRateLimiter = createSubmitRateLimiter(), ): Required { const pushNotification: Required["pushNotification"] = async ({ text, @@ -34,6 +39,7 @@ export function createNotificationAdapters( "Notifications", { kind: "Device", + limiter, }, modalScope, ); diff --git a/packages/ui/src/host-callbacks/SessionStore.ts b/packages/ui/src/host-callbacks/SessionStore.ts index efb4f9ae..b8783e60 100644 --- a/packages/ui/src/host-callbacks/SessionStore.ts +++ b/packages/ui/src/host-callbacks/SessionStore.ts @@ -78,21 +78,54 @@ export async function writeUiStateCache( } } +function isOptionalString(value: unknown): value is string | undefined { + return value === undefined || typeof value === "string"; +} + +/** Validate a parsed UI-state cache blob field by field. The cache is + * host-written same-origin data, but it can be stale from a different code + * version or partially corrupted, so a malformed blob degrades to null + * (bare connected state) rather than being cast into the typed shape. */ +function parseUiStateCache(parsed: unknown): TruapiSessionUiState | null { + if (typeof parsed !== "object" || parsed === null) { + return null; + } + const state = parsed as Record; + if ( + state.connected !== true || + !isOptionalString(state.publicKey) || + !isOptionalString(state.identityAccountId) || + !isOptionalString(state.liteUsername) || + !isOptionalString(state.fullUsername) || + !isOptionalString(state.primaryUsername) + ) { + return null; + } + return { + connected: true, + ...(state.publicKey !== undefined ? { publicKey: state.publicKey } : {}), + ...(state.identityAccountId !== undefined + ? { identityAccountId: state.identityAccountId } + : {}), + ...(state.liteUsername !== undefined + ? { liteUsername: state.liteUsername } + : {}), + ...(state.fullUsername !== undefined + ? { fullUsername: state.fullUsername } + : {}), + ...(state.primaryUsername !== undefined + ? { primaryUsername: state.primaryUsername } + : {}), + }; +} + async function readUiStateCache(): Promise { try { const raw = await readSharedAuthStorage(SITE_ID, UI_STATE_CACHE_KEY); if (raw === null) { return null; } - const parsed: unknown = JSON.parse(raw); - if ( - typeof parsed === "object" && - parsed !== null && - (parsed as TruapiSessionUiState).connected - ) { - return parsed as TruapiSessionUiState; - } - return null; + return parseUiStateCache(JSON.parse(raw)); } catch { return null; } @@ -153,9 +186,7 @@ async function readCoreStorageValue( return decodeStoredBytes(raw, "shared auth session"); } const raw = localStorage.getItem(coreLocalStorageKey(key)); - return raw === null - ? undefined - : decodeStoredBytes(raw, `core storage ${key.tag}`); + return raw === null ? undefined : await decodeCoreStorageValue(key, raw); } function decodeStoredBytes( @@ -183,7 +214,10 @@ async function writeCoreStorageValue( emitLocalChange(); return; } - localStorage.setItem(coreLocalStorageKey(key), bytesToHex(value)); + localStorage.setItem( + coreLocalStorageKey(key), + await encodeCoreStorageValue(key, value), + ); } async function clearCoreStorageValue(key: CoreStorageKey): Promise { @@ -213,6 +247,204 @@ function coreLocalStorageKey(key: CoreStorageKey): string { } } +async function encodeCoreStorageValue( + key: CoreStorageKey, + value: Uint8Array, +): Promise { + if (key.tag === "AllowanceKeys") { + const nonce = crypto.getRandomValues( + new Uint8Array(ALLOWANCE_NONCE_LENGTH), + ); + const ciphertext = new Uint8Array( + await crypto.subtle.encrypt( + { name: "AES-GCM", iv: nonce }, + await allowanceStorageKey(), + new Uint8Array(value), + ), + ); + const stored = new Uint8Array(nonce.length + ciphertext.length); + stored.set(nonce); + stored.set(ciphertext, nonce.length); + return ENCRYPTED_VALUE_PREFIX + bytesToHex(stored); + } + return bytesToHex(value); +} + +async function decodeCoreStorageValue( + key: CoreStorageKey, + raw: string, +): Promise { + if (key.tag !== "AllowanceKeys") { + return decodeStoredBytes(raw, `core storage ${key.tag}`); + } + if (!raw.startsWith(ENCRYPTED_VALUE_PREFIX)) { + // Slots written before at-rest encryption shipped hold the plain key + // bytes. Re-persist encrypted so the plaintext copy doesn't outlive + // this read. + const bytes = decodeStoredBytes(raw, `core storage ${key.tag}`); + if (bytes === undefined) { + return undefined; + } + log.warn(`[dot.li] re-encrypting legacy plaintext core storage ${key.tag}`); + localStorage.setItem( + coreLocalStorageKey(key), + await encodeCoreStorageValue(key, bytes), + ); + return bytes; + } + const bytes = decodeStoredBytes( + raw.slice(ENCRYPTED_VALUE_PREFIX.length), + `core storage ${key.tag}`, + ); + if (bytes === undefined) { + return undefined; + } + try { + return new Uint8Array( + await crypto.subtle.decrypt( + { name: "AES-GCM", iv: bytes.slice(0, ALLOWANCE_NONCE_LENGTH) }, + await allowanceStorageKey(), + bytes.slice(ALLOWANCE_NONCE_LENGTH), + ), + ); + } catch (err) { + // The slot was written under a key we no longer hold (IndexedDB + // cleared while localStorage survived, or a session-ephemeral fallback + // key) or the bytes are corrupt. Drop it: returning the raw bytes + // would hand ciphertext to the core as key material. + log.warn(`[dot.li] dropping undecryptable core storage ${key.tag}:`, err); + localStorage.removeItem(coreLocalStorageKey(key)); + return undefined; + } +} + +// Marks a slot as holding the encrypted format. Legacy plaintext slots are +// bare hex, so the prefix cleanly separates "must decrypt" from "migrate": +// a decrypt failure never falls back to treating ciphertext as plaintext. +const ENCRYPTED_VALUE_PREFIX = "enc1:"; + +// Standard AES-GCM nonce length. A fresh random nonce is drawn per write and +// stored as the ciphertext prefix: GCM security collapses if a (key, nonce) +// pair is ever reused. +const ALLOWANCE_NONCE_LENGTH = 12; + +const KEY_DB_NAME = "dotli-core"; +const KEY_DB_STORE = "keys"; +const ALLOWANCE_KEY_ID = "allowance-keys"; + +let allowanceKeyPromise: Promise | undefined; + +/** + * The at-rest key for AllowanceKeys slots: a random per-install AES key + * generated non-extractable and persisted in IndexedDB, so the key material + * itself can never be read out of the browser's crypto implementation — a + * key derived from bundle data would be computable by anyone. If IndexedDB + * is unavailable the key degrades to session-ephemeral: values written then + * fail to decrypt after a reload and are dropped like any corrupt slot. + */ +function allowanceStorageKey(): Promise { + allowanceKeyPromise ??= loadOrCreateAllowanceKey().catch((err: unknown) => { + log.warn( + "[dot.li] falling back to a session-ephemeral allowance storage key:", + err, + ); + return generateAllowanceKey(); + }); + return allowanceKeyPromise; +} + +function generateAllowanceKey(): Promise { + return crypto.subtle.generateKey({ name: "AES-GCM", length: 256 }, false, [ + "encrypt", + "decrypt", + ]); +} + +async function loadOrCreateAllowanceKey(): Promise { + const db = await openKeyDb(); + try { + const existing = await idbGetKey(db); + if (existing !== undefined) { + return existing; + } + const key = await generateAllowanceKey(); + try { + await idbAddKey(db, key); + return key; + } catch (err) { + // add() rejects when the slot is already taken: another tab won the + // race, so adopt its key instead of splitting the install across two. + const winner = await idbGetKey(db); + if (winner !== undefined) { + return winner; + } + throw err; + } + } finally { + db.close(); + } +} + +function openKeyDb(): Promise { + return new Promise((resolve, reject) => { + const request = indexedDB.open(KEY_DB_NAME, 1); + request.onupgradeneeded = () => { + request.result.createObjectStore(KEY_DB_STORE); + }; + request.onsuccess = () => { + resolve(request.result); + }; + request.onerror = () => { + reject(request.error ?? new Error("indexedDB open failed")); + }; + }); +} + +function idbGetKey(db: IDBDatabase): Promise { + return new Promise((resolve, reject) => { + const request = db + .transaction(KEY_DB_STORE) + .objectStore(KEY_DB_STORE) + .get(ALLOWANCE_KEY_ID); + request.onsuccess = () => { + const value: unknown = request.result; + resolve(isCryptoKey(value) ? value : undefined); + }; + request.onerror = () => { + reject(request.error ?? new Error("indexedDB get failed")); + }; + }); +} + +function idbAddKey(db: IDBDatabase, key: CryptoKey): Promise { + return new Promise((resolve, reject) => { + const tx = db.transaction(KEY_DB_STORE, "readwrite"); + tx.objectStore(KEY_DB_STORE).add(key, ALLOWANCE_KEY_ID); + tx.oncomplete = () => { + resolve(); + }; + tx.onerror = () => { + reject(tx.error ?? new Error("indexedDB add failed")); + }; + // A commit-time abort (e.g. QuotaExceededError) fires only `abort`; + // without this the promise never settles and, being memoized, would + // hang every allowance read/write for the session. + tx.onabort = () => { + reject(tx.error ?? new Error("indexedDB add aborted")); + }; + }); +} + +/** `instanceof CryptoKey` is unreliable across realms (and the global is + * missing under happy-dom), so validate the stored record structurally. */ +function isCryptoKey(value: unknown): value is CryptoKey { + return ( + typeof value === "object" && + value !== null && + (value as CryptoKey).type === "secret" + ); +} + function hexNoPrefix(bytes: Uint8Array): string { return bytesToHex(bytes).slice(2); } diff --git a/packages/ui/src/host-callbacks/handlers.ts b/packages/ui/src/host-callbacks/handlers.ts index e7d07f24..1a25ab20 100644 --- a/packages/ui/src/host-callbacks/handlers.ts +++ b/packages/ui/src/host-callbacks/handlers.ts @@ -27,6 +27,7 @@ import { createBlockingModalScope, type BlockingModalScope, } from "../blocking-modal-queue"; +import { createSubmitRateLimiter } from "./rate-limit"; export interface CreateHostCallbacksOptions { label: string; @@ -46,10 +47,21 @@ export function createHostCallbacks( pairingHostGlobal, blockingModalScope = createBlockingModalScope(), } = options; + // Permission and notification prompts draw from one budget so a product + // cannot double its prompt rate by alternating prompt kinds. + const promptLimiter = createSubmitRateLimiter(); return { navigation: { navigateTo: createNavigateTo() }, - notifications: createNotificationAdapters(label, blockingModalScope), - permissions: createPromptPermission(label, blockingModalScope), + notifications: createNotificationAdapters( + label, + blockingModalScope, + promptLimiter, + ), + permissions: createPromptPermission( + label, + blockingModalScope, + promptLimiter, + ), features: { featureSupported: createFeatureSupported() }, productStorage: { read: createLocalStorageRead(), diff --git a/packages/ui/src/host-callbacks/rate-limit.ts b/packages/ui/src/host-callbacks/rate-limit.ts new file mode 100644 index 00000000..fa166f72 --- /dev/null +++ b/packages/ui/src/host-callbacks/rate-limit.ts @@ -0,0 +1,25 @@ +// Sliding-window rate limiter shared across host callbacks. + +const SUBMIT_WINDOW_MS = 10_000; +const SUBMIT_MAX_PER_WINDOW = 20; + +export interface SubmitRateLimiter { + allow: () => boolean; +} + +export function createSubmitRateLimiter(): SubmitRateLimiter { + const timestamps: number[] = []; + return { + allow() { + const now = Date.now(); + while (timestamps.length > 0 && timestamps[0] <= now - SUBMIT_WINDOW_MS) { + timestamps.shift(); + } + if (timestamps.length >= SUBMIT_MAX_PER_WINDOW) { + return false; + } + timestamps.push(now); + return true; + }, + }; +} diff --git a/packages/ui/src/legacy-host-bridge.ts b/packages/ui/src/legacy-host-bridge.ts index 7e82b1ae..68ce5a79 100644 --- a/packages/ui/src/legacy-host-bridge.ts +++ b/packages/ui/src/legacy-host-bridge.ts @@ -7,8 +7,12 @@ // ids to PAPI, so the legacy provider translates those ids back to their TrUAPI // subscription wire ids. No `@novasamatech/*` dependency is needed. // -// Remove this file (and the probe in `bridge.ts`) once products migrate to -// `@parity/truapi`. +// TODO(remove-legacy-nova): once the last product on the legacy Nova host-api +// stack migrates to `@parity/truapi`, delete this entire file together with +// every other site tagged `remove-legacy-nova` (grep for that tag): the +// window-postMessage probe in `bridge.ts` and +// `tests/legacy-host-bridge.test.ts`. Nothing in the modern MessagePort path +// or the Rust core depends on anything here. import { decodeWireMessage, @@ -48,6 +52,8 @@ interface VersionedFollowBoundRequest { interface DecodedFollowBoundRequest { genesisHash: string; + /** The synthetic follow id Nova stamped on the incoming frame. */ + followSubscriptionId: string; withFollowSubscriptionId(followSubscriptionId: string): Uint8Array; } @@ -60,6 +66,7 @@ function createFollowBoundDecoder( const request = codec.dec(payload); return { genesisHash: request.value.genesisHash, + followSubscriptionId: request.value.followSubscriptionId, withFollowSubscriptionId(followSubscriptionId) { if (request.value.followSubscriptionId === followSubscriptionId) { return payload; @@ -114,24 +121,42 @@ export interface WindowMessageProvider extends WireProvider { /** * Translate Nova's synthetic chain-head follow ids at the legacy transport - * boundary. The old Nova host selected the first active follow for a chain; - * retain that behavior while the Rust core requires the exact subscription - * start frame's request id. + * boundary. Nova numbers each follow on a chain monotonically (`follow_0`, + * `follow_1`, …) while the Rust core requires the exact subscription start + * frame's request id, so the shim mirrors that counter per genesis and + * rewrites every follow-bound frame to the wire id of the follow it is bound + * to. A product can hold several concurrent follows on one genesis (PAPI + * opens a fresh follow during resync before stopping the previous one); + * routing by the frame's own synthetic id keeps those from cross-talking. + * A synthetic id the shim never saw falls back to the first active follow. */ export function createLegacyNovaChainHeadProvider( provider: WireProvider, productId?: string, ): WireProvider { - const followIdsByGenesis = new Map>(); + /** Per genesis: Nova's synthetic follow id -> follow-start wire request id. */ + const followWireIdsByGenesis = new Map>(); + // Nova's synthetic counter never resets within a connection — not even + // after every follow on a genesis stops — so this mirror only resets where + // Nova's does: handshake (new document) and dispose. + const nextSyntheticOrdinal = new Map(); const localProductId = productId === "localhost" || productId?.startsWith("localhost:") === true ? productId : undefined; + const forgetAllFollows = (): void => { + followWireIdsByGenesis.clear(); + nextSyntheticOrdinal.clear(); + }; const forgetFollowId = (requestId: string): void => { - for (const [genesisHash, followIds] of followIdsByGenesis) { - followIds.delete(requestId); + for (const [genesisHash, followIds] of followWireIdsByGenesis) { + for (const [syntheticId, wireId] of followIds) { + if (wireId === requestId) { + followIds.delete(syntheticId); + } + } if (followIds.size === 0) { - followIdsByGenesis.delete(genesisHash); + followWireIdsByGenesis.delete(genesisHash); } } }; @@ -147,7 +172,7 @@ export function createLegacyNovaChainHeadProvider( // The core-side provider survives that navigation, so discard mappings // belonging to the previous document before accepting new follows. if (payload.id === SYSTEM_HANDSHAKE.request) { - followIdsByGenesis.clear(); + forgetAllFollows(); return message; } if ( @@ -185,9 +210,12 @@ export function createLegacyNovaChainHeadProvider( // Let the core report malformed legacy frames. return message; } - const followIds = followIdsByGenesis.get(genesisHash) ?? new Set(); - followIds.add(requestId); - followIdsByGenesis.set(genesisHash, followIds); + const followIds = + followWireIdsByGenesis.get(genesisHash) ?? new Map(); + const ordinal = nextSyntheticOrdinal.get(genesisHash) ?? 0; + followIds.set(`follow_${String(ordinal)}`, requestId); + nextSyntheticOrdinal.set(genesisHash, ordinal + 1); + followWireIdsByGenesis.set(genesisHash, followIds); return message; } @@ -203,10 +231,10 @@ export function createLegacyNovaChainHeadProvider( try { const request = decodeFollowBoundRequest(payload.value); - const followId = followIdsByGenesis - .get(request.genesisHash) - ?.values() - .next().value; + const followIds = followWireIdsByGenesis.get(request.genesisHash); + const followId = + followIds?.get(request.followSubscriptionId) ?? + followIds?.values().next().value; if (followId === undefined) { return message; } @@ -244,7 +272,7 @@ export function createLegacyNovaChainHeadProvider( }, subscribeClose, dispose() { - followIdsByGenesis.clear(); + forgetAllFollows(); provider.dispose(); }, }; diff --git a/packages/ui/src/permissions.ts b/packages/ui/src/permissions.ts index 7be53565..3f142767 100644 --- a/packages/ui/src/permissions.ts +++ b/packages/ui/src/permissions.ts @@ -199,8 +199,8 @@ export async function getPermissionStatus( label: string, permission: PermissionName, ): Promise { - const [status] = await getPermissionStatuses(label, [permission]); - return status; + const statuses = await getPermissionStatuses(label, [permission]); + return statuses.at(0) ?? "ask"; } export async function getPermissionStatuses( diff --git a/packages/ui/src/runtime-config.ts b/packages/ui/src/runtime-config.ts index 822e3768..6869afdd 100644 --- a/packages/ui/src/runtime-config.ts +++ b/packages/ui/src/runtime-config.ts @@ -1,8 +1,6 @@ import { getActiveServicesConfig } from "@dotli/config/network"; import type { ProductRuntimeConfig } from "@parity/truapi-host"; -type RuntimeLocation = Pick; - declare const __DOTLI_VERSION__: string | undefined; export function labelToProductId(label: string): string { @@ -22,12 +20,12 @@ function getPlatformType(userAgent: string = navigator.userAgent): string { return "Unknown"; } +// The window origin deliberately plays no part here: `productId` comes +// solely from the label (or the explicit override). export function createTruapiRuntimeConfig( label: string, - location: RuntimeLocation = window.location, productId: string = labelToProductId(label), ): ProductRuntimeConfig { - void location; return { productId, host: { diff --git a/packages/ui/src/topbar.ts b/packages/ui/src/topbar.ts index 8791b73e..8ce231e5 100644 --- a/packages/ui/src/topbar.ts +++ b/packages/ui/src/topbar.ts @@ -478,7 +478,32 @@ const PENDING_ICON_SVG = // fall back to the raw error. function friendlyAuthError( message: string, -): { title: string; subtitle: string } | null { +): { title: string; subtitle: string; detail?: string } | null { + if ( + message.includes("no free statement-store slot for device registration") + ) { + return { + title: "No Statement Store slots left", + subtitle: "Polkadot Mobile could not register this browser as a device.", + detail: "no free statement-store slot for device registration", + }; + } + if (message.includes("Invalid Transaction")) { + return { + title: "Statement Store transaction rejected", + subtitle: + "Polkadot Mobile could not register this browser because the chain rejected the registration transaction.", + detail: message, + }; + } + if (message.includes("SubstrateSdk.JSONRPCError error 1")) { + return { + title: "Statement Store registration failed", + subtitle: + "Polkadot Mobile reported a JSON-RPC failure while registering this browser as a device.", + detail: message, + }; + } if (message.includes("OriginPersonProviderError")) { return { title: "Your account is still being set up", @@ -508,6 +533,13 @@ function renderError(message: string): void { subtitle.className = "auth-modal-pending-subtitle"; subtitle.textContent = friendly.subtitle; container.appendChild(subtitle); + + if (friendly.detail !== undefined && friendly.detail.length > 0) { + const detail = document.createElement("p"); + detail.className = "auth-modal-error"; + detail.textContent = friendly.detail; + container.appendChild(detail); + } } else { const msg = document.createElement("p"); msg.className = "auth-modal-error"; @@ -671,6 +703,7 @@ const STATUS_LABELS: Record = { const STATUS_ORDER: readonly PermissionStatus[] = ["ask", "granted", "denied"]; let openDropdownCleanup: (() => void) | null = null; +let permissionsRenderToken = 0; function closeOpenDropdown(): void { openDropdownCleanup?.(); @@ -678,12 +711,20 @@ function closeOpenDropdown(): void { } function renderPermissionsPopover(): void { - void renderPermissionsPopoverAsync().catch(() => { + const token = ++permissionsRenderToken; + void renderPermissionsPopoverAsync(token).catch(() => { + if (token !== permissionsRenderToken) { + return; + } permissionsPopoverList.innerHTML = ""; + const hint = document.createElement("div"); + hint.className = "permissions-popover-footer"; + hint.textContent = "Permissions are unavailable for this app."; + permissionsPopoverList.appendChild(hint); }); } -async function renderPermissionsPopoverAsync(): Promise { +async function renderPermissionsPopoverAsync(token: number): Promise { closeOpenDropdown(); permissionsPopoverList.innerHTML = ""; @@ -705,7 +746,10 @@ async function renderPermissionsPopoverAsync(): Promise { for (const [index, perm] of ALL_PERMISSIONS.entries()) { const status = statuses[index] ?? "ask"; - if (currentProductLabel !== productLabel) { + if ( + token !== permissionsRenderToken || + currentProductLabel !== productLabel + ) { return; } diff --git a/packages/ui/tests/legacy-host-bridge.test.ts b/packages/ui/tests/legacy-host-bridge.test.ts index 4fed6ab1..3fdd9d6a 100644 --- a/packages/ui/tests/legacy-host-bridge.test.ts +++ b/packages/ui/tests/legacy-host-bridge.test.ts @@ -1,3 +1,6 @@ +// TODO(remove-legacy-nova): delete this file together with the tagged +// `legacy-host-bridge.ts` module it covers. + import { describe, expect, it, vi } from "vitest"; import { decodeWireMessage, @@ -465,6 +468,88 @@ describe("createLegacyNovaChainHeadProvider", () => { ).toBe("wire-new"); }); + it("As a dotli integrator, the host routes each follow-bound request to its own concurrent follow", () => { + // Given: two concurrent follows on the same genesis (PAPI opens a fresh + // follow during resync before stopping the previous one), which Nova + // exposes to the product as follow_0 and follow_1. + const harness = createHarness(); + harness.emit(followStart("wire-follow-a")); + harness.emit(followStart("wire-follow-b")); + + const emitHeader = (syntheticId: string, requestId: string): string => { + harness.emit( + followBoundFrame( + requestId, + CHAIN_GET_HEAD_HEADER.request, + VersionedRemoteChainHeadHeaderRequest, + { + tag: "V1", + value: { + genesisHash, + followSubscriptionId: syntheticId, + hash: blockHash, + }, + }, + ), + ); + return decodedFollowId( + harness.received.at(-1)!, + VersionedRemoteChainHeadHeaderRequest, + ); + }; + + // Then: each op reaches the follow it is bound to, not the first active. + expect(emitHeader("follow_0", "op-0")).toBe("wire-follow-a"); + expect(emitHeader("follow_1", "op-1")).toBe("wire-follow-b"); + + // When the older follow stops, follow_1 ops still reach their follow. + harness.emit(followStop("wire-follow-a")); + + // Then + expect(emitHeader("follow_1", "op-2")).toBe("wire-follow-b"); + + // Then: a synthetic id the shim never saw falls back to the first + // active follow instead of passing through unmapped. + expect(emitHeader("follow_9", "op-3")).toBe("wire-follow-b"); + }); + + it("As a dotli integrator, the host keeps mirroring Nova's follow numbering across a full stop", () => { + // Given: Nova's synthetic counter never resets within a connection, so a + // follow started after every earlier follow stopped is follow_1, not + // follow_0. + const harness = createHarness(); + harness.emit(followStart("wire-follow-a")); + harness.emit(followStop("wire-follow-a")); + harness.emit(followStart("wire-follow-b")); + harness.emit(followStart("wire-follow-c")); + + const emitHeader = (syntheticId: string, requestId: string): string => { + harness.emit( + followBoundFrame( + requestId, + CHAIN_GET_HEAD_HEADER.request, + VersionedRemoteChainHeadHeaderRequest, + { + tag: "V1", + value: { + genesisHash, + followSubscriptionId: syntheticId, + hash: blockHash, + }, + }, + ), + ); + return decodedFollowId( + harness.received.at(-1)!, + VersionedRemoteChainHeadHeaderRequest, + ); + }; + + // Then: ops route by Nova's numbering, not a restarted count. + expect(emitHeader("follow_1", "op-0")).toBe("wire-follow-b"); + expect(emitHeader("follow_2", "op-1")).toBe("wire-follow-c"); + }); + it("As a dotli integrator, the host owns and disposes the wrapped provider", () => { // Given const harness = createHarness(); diff --git a/packages/ui/tests/permissions.test.ts b/packages/ui/tests/permissions.test.ts index 9142b9f6..0489d488 100644 --- a/packages/ui/tests/permissions.test.ts +++ b/packages/ui/tests/permissions.test.ts @@ -81,6 +81,25 @@ describe("getPermissionStatus / setPermissionStatus", () => { ); }); + it("As a product, my status defaults to ask when the provider returns fewer statuses than requested", async () => { + // Given: a provider that violates the length contract. + const unregister = registerPermissionAuthorizationProvider("shortapp", { + async getPermissionAuthorizationStatuses() { + return []; + }, + async setPermissionAuthorizationStatus() { + return; + }, + }); + + try { + // Then: the missing entry surfaces as "ask", not undefined. + expect(await getPermissionStatus("shortapp", "Camera")).toBe("ask"); + } finally { + unregister(); + } + }); + it("As a product, my granted permission status is preserved", async () => { await setPermissionStatus("myapp", "Camera", "granted"); expect(await getPermissionStatus("myapp", "Camera")).toBe("granted"); diff --git a/packages/ui/tests/preimage.test.ts b/packages/ui/tests/preimage.test.ts index 1d8651e5..c1b3b683 100644 --- a/packages/ui/tests/preimage.test.ts +++ b/packages/ui/tests/preimage.test.ts @@ -45,31 +45,49 @@ describe("preimage host callbacks", () => { expect(first.value._unsafeUnwrap()).toBeUndefined(); }); + it("only exposes the lookup callback (submission is core-owned)", () => { + const adapters = createPreimageAdapters("myapp"); + expect(typeof adapters.lookupPreimage).toBe("function"); + expect("submitPreimage" in adapters).toBe(false); + }); + it("As a dotli integrator, the host retries transient lookup backend failures", async () => { // Given vi.useFakeTimers(); try { const { lookupPreimage } = createPreimageAdapters("myapp"); - const missingKey = new Uint8Array(32); + const found = new TextEncoder().encode("retried preimage"); + const key = fromHex(computePreimageKey(found)); mocks.fetchFromIpfs.mockRejectedValueOnce( new Error("gateway unavailable"), ); + mocks.fetchFromIpfs.mockResolvedValueOnce({ data: found }); // When - const iterator = lookupPreimage(missingKey)[Symbol.asyncIterator](); + const iterator = lookupPreimage(key)[Symbol.asyncIterator](); const first = await iterator.next(); + const secondPromise = iterator.next(); + let secondSettled = false; + void secondPromise.then(() => { + secondSettled = true; + }); await vi.advanceTimersByTimeAsync(1000); // Then expect(first.done).toBe(false); expect(first.value.isOk()).toBe(true); expect(first.value._unsafeUnwrap()).toBeUndefined(); + expect(secondSettled).toBe(false); expect(mocks.fetchFromIpfs).toHaveBeenCalledTimes(1); // When await vi.advanceTimersByTimeAsync(9_000); // Then + const second = await secondPromise; + expect(second.done).toBe(false); + expect(second.value.isOk()).toBe(true); + expect(second.value._unsafeUnwrap()).toEqual(found); expect(mocks.fetchFromIpfs).toHaveBeenCalledTimes(2); await iterator.return?.(); } finally { diff --git a/packages/ui/tests/rate-limit.test.ts b/packages/ui/tests/rate-limit.test.ts new file mode 100644 index 00000000..ed52bc7e --- /dev/null +++ b/packages/ui/tests/rate-limit.test.ts @@ -0,0 +1,126 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createSubmitRateLimiter } from "@dotli/ui/host-callbacks/rate-limit"; + +const mocks = vi.hoisted(() => ({ + scheduleNotification: vi.fn(), + cancelNotification: vi.fn(), + showPermissionRequestModal: vi.fn(), +})); + +vi.mock("@dotli/ui/scheduled-notifications", () => ({ + scheduleNotification: mocks.scheduleNotification, + cancelNotification: mocks.cancelNotification, +})); + +vi.mock("@dotli/ui/permission-modal", () => ({ + showPermissionRequestModal: mocks.showPermissionRequestModal, +})); + +// Mirror of SUBMIT_WINDOW_MS / SUBMIT_MAX_PER_WINDOW in rate-limit.ts. +const WINDOW_MS = 10_000; +const MAX_PER_WINDOW = 20; + +describe("createSubmitRateLimiter", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("As a dotli integrator, the host allows prompts up to the window budget and denies the next", () => { + // Given + const limiter = createSubmitRateLimiter(); + + // When / Then + for (let i = 0; i < MAX_PER_WINDOW; i += 1) { + expect(limiter.allow()).toBe(true); + } + expect(limiter.allow()).toBe(false); + }); + + it("As a dotli integrator, the host frees budget as prompts age out of the sliding window", () => { + // Given: half the budget spent now, the other half just before the + // window boundary. + const limiter = createSubmitRateLimiter(); + for (let i = 0; i < MAX_PER_WINDOW / 2; i += 1) { + limiter.allow(); + } + vi.advanceTimersByTime(WINDOW_MS - 1); + for (let i = 0; i < MAX_PER_WINDOW / 2; i += 1) { + limiter.allow(); + } + + // Then: the window is full until the first half expires. + expect(limiter.allow()).toBe(false); + + // When: the first half ages out. + vi.advanceTimersByTime(1); + + // Then: exactly that much budget is back, no more. + for (let i = 0; i < MAX_PER_WINDOW / 2; i += 1) { + expect(limiter.allow()).toBe(true); + } + expect(limiter.allow()).toBe(false); + }); + + it("As a dotli integrator, the host denials do not consume budget", () => { + // Given: a full window. + const limiter = createSubmitRateLimiter(); + for (let i = 0; i < MAX_PER_WINDOW; i += 1) { + limiter.allow(); + } + + // When: repeated denied attempts inside the window. + expect(limiter.allow()).toBe(false); + expect(limiter.allow()).toBe(false); + + // Then: once the window empties, the full budget is available again — + // denied attempts did not extend it. + vi.advanceTimersByTime(WINDOW_MS); + expect(limiter.allow()).toBe(true); + }); +}); + +describe("prompt rate limiting across host callbacks", () => { + beforeEach(() => { + vi.clearAllMocks(); + localStorage.clear(); + document.body.innerHTML = ""; + mocks.showPermissionRequestModal.mockResolvedValue("granted"); + mocks.scheduleNotification.mockResolvedValue({ + ok: true, + id: 7, + immediate: false, + }); + }); + + it("As a dotli integrator, the host counts permission and notification prompts against one shared budget", async () => { + // Given: a single host callback surface. No authorization provider is + // registered, so every prompt reaches the "ask" path and the limiter. + const { createHostCallbacks } = + await import("@dotli/ui/host-callbacks/handlers"); + const { permissions, notifications } = createHostCallbacks({ + label: "myapp", + }); + + // When: permission prompts exhaust the whole window budget. + for (let i = 0; i < MAX_PER_WINDOW; i += 1) { + await permissions.devicePermission("Camera"); + } + + // Then: a notification prompt shares that budget and is rate limited + // instead of showing a 21st modal. + await expect( + notifications.pushNotification({ + text: "hello", + deeplink: undefined, + scheduledAt: undefined, + }), + ).rejects.toThrow("Permission prompt rate limited"); + expect(mocks.showPermissionRequestModal).toHaveBeenCalledTimes( + MAX_PER_WINDOW, + ); + }); +}); diff --git a/packages/ui/tests/runtime-config.test.ts b/packages/ui/tests/runtime-config.test.ts index 31ffdc80..0ccb0b18 100644 --- a/packages/ui/tests/runtime-config.test.ts +++ b/packages/ui/tests/runtime-config.test.ts @@ -18,23 +18,13 @@ describe("labelToProductId", () => { describe("createTruapiRuntimeConfig", () => { it("As a dotli integrator, the host accepts an explicit product id for local previews", () => { expect( - createTruapiRuntimeConfig( - "localhost:3000", - { - origin: "http://localhost:5173", - } as Location, - "truapi-playground.dot", - ).productId, + createTruapiRuntimeConfig("localhost:3000", "truapi-playground.dot") + .productId, ).toBe("truapi-playground.dot"); }); it("As a dotli integrator, the host passes the full host runtime contract to the WASM core", () => { - expect( - createTruapiRuntimeConfig("acme", { - hostname: "host.dot.li", - origin: "https://host.dot.li", - } as Location), - ).toEqual({ + expect(createTruapiRuntimeConfig("acme")).toEqual({ productId: "acme.dot", host: { name: "Polkadot Web", diff --git a/packages/ui/tests/session-store.test.ts b/packages/ui/tests/session-store.test.ts index f9f9f3c8..734c1f1d 100644 --- a/packages/ui/tests/session-store.test.ts +++ b/packages/ui/tests/session-store.test.ts @@ -1,3 +1,4 @@ +import "fake-indexeddb/auto"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { SHARED_CORE_SESSION_KEY } from "@dotli/protocol/auth-storage"; import { SITE_ID } from "@dotli/config/config"; @@ -166,7 +167,7 @@ describe("session-store host callbacks", () => { expect(localStorage.length).toBe(1); }); - it("As a dotli integrator, the host round-trips persisted allowance key slots", async () => { + it("As a dotli integrator, the host encrypts persisted allowance key slots", async () => { // Given const { readCoreStorage, writeCoreStorage, clearCoreStorage } = createSessionStoreAdapters(); @@ -180,7 +181,7 @@ describe("session-store host callbacks", () => { // Then const storageKey = "dotli:core:allowance-keys:session-1"; - expect(localStorage.getItem(storageKey)).toBe("0x01020304"); + expect(localStorage.getItem(storageKey)).not.toBe("0x01020304"); expect(Array.from((await readCoreStorage(key)) ?? [])).toEqual([ 1, 2, 3, 4, ]); @@ -192,6 +193,111 @@ describe("session-store host callbacks", () => { expect(await readCoreStorage(key)).toBeUndefined(); }); + it("As a dotli integrator, the host never reuses a nonce across allowance key writes", async () => { + // Given + const { readCoreStorage, writeCoreStorage } = createSessionStoreAdapters(); + const key = { + tag: "AllowanceKeys", + value: { sessionId: "session-1" }, + } satisfies CoreStorageKey; + const storageKey = "dotli:core:allowance-keys:session-1"; + + // When: the same plaintext is written twice + await writeCoreStorage(key, new Uint8Array([1, 2, 3, 4])); + const first = localStorage.getItem(storageKey); + await writeCoreStorage(key, new Uint8Array([1, 2, 3, 4])); + const second = localStorage.getItem(storageKey); + + // Then: the ciphertexts differ (fresh nonce per write) and still decrypt + expect(first).not.toBeNull(); + expect(second).not.toBeNull(); + expect(second).not.toBe(first); + expect(Array.from((await readCoreStorage(key)) ?? [])).toEqual([ + 1, 2, 3, 4, + ]); + }); + + it("As a dotli integrator, the host encrypts allowance keys under a non-extractable per-install key", async () => { + // Given + const { writeCoreStorage } = createSessionStoreAdapters(); + const key = { + tag: "AllowanceKeys", + value: { sessionId: "session-1" }, + } satisfies CoreStorageKey; + + // When + await writeCoreStorage(key, new Uint8Array([1, 2, 3, 4])); + + // Then: the encryption key is a random per-install CryptoKey persisted + // in IndexedDB whose material can never be exported, not something + // derivable from public bundle data. + const db = await new Promise((resolve, reject) => { + const request = indexedDB.open("dotli-core"); + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); + const stored = await new Promise( + (resolve, reject) => { + const request = db + .transaction("keys") + .objectStore("keys") + .get("allowance-keys"); + request.onsuccess = () => resolve(request.result as CryptoKey); + request.onerror = () => reject(request.error); + }, + ); + db.close(); + expect(stored?.type).toBe("secret"); + expect(stored?.extractable).toBe(false); + await expect( + crypto.subtle.exportKey("raw", stored as CryptoKey), + ).rejects.toThrow(); + }); + + it("As a dotli integrator, the host migrates legacy plaintext allowance keys on read", async () => { + // Given: a plain-hex slot written before at-rest encryption shipped + const { readCoreStorage } = createSessionStoreAdapters(); + const key = { + tag: "AllowanceKeys", + value: { sessionId: "legacy" }, + } satisfies CoreStorageKey; + const storageKey = "dotli:core:allowance-keys:legacy"; + localStorage.setItem(storageKey, "0x01020304"); + + // When + const bytes = await readCoreStorage(key); + + // Then: the legacy bytes are readable and re-persisted encrypted + expect(Array.from(bytes ?? [])).toEqual([1, 2, 3, 4]); + expect(localStorage.getItem(storageKey)).toMatch(/^enc1:0x/); + expect(Array.from((await readCoreStorage(key)) ?? [])).toEqual([ + 1, 2, 3, 4, + ]); + }); + + it("As a dotli integrator, the host drops allowance slots that no longer decrypt instead of returning ciphertext", async () => { + // Given: an encrypted slot whose ciphertext no longer authenticates — + // the same shape as a key lost to an IndexedDB wipe or tampered bytes + const { readCoreStorage, writeCoreStorage } = createSessionStoreAdapters(); + const key = { + tag: "AllowanceKeys", + value: { sessionId: "session-1" }, + } satisfies CoreStorageKey; + const storageKey = "dotli:core:allowance-keys:session-1"; + await writeCoreStorage(key, new Uint8Array([1, 2, 3, 4])); + const stored = localStorage.getItem(storageKey) ?? ""; + const flipped = stored.slice(0, -2) + (stored.endsWith("00") ? "ff" : "00"); + localStorage.setItem(storageKey, flipped); + + // When + const bytes = await readCoreStorage(key); + + // Then: a true cache miss — ciphertext is never handed back as key + // material, and the dead slot is removed rather than re-encrypted + expect(bytes).toBeUndefined(); + expect(localStorage.getItem(storageKey)).toBeNull(); + }); + it("As a dotli integrator, the host treats corrupt persisted core bytes as a cache miss", async () => { // Given const { readCoreStorage } = createSessionStoreAdapters(); @@ -379,6 +485,31 @@ describe("session-store host callbacks", () => { ]); }); + it("As a dotli integrator, the host degrades to a bare connected state when the cached UI state is malformed", async () => { + // Given: a persisted session, but a UI-state cache whose fields no longer + // match the expected shape (e.g. written by a different code version). + const { writeCoreStorage } = createSessionStoreAdapters(); + const events: unknown[] = []; + window.addEventListener("dotli:truapi-auth-state", (event) => { + events.push((event as CustomEvent).detail); + }); + await writeCoreStorage(AUTH_SESSION_KEY, new Uint8Array([1, 2, 3])); + sharedAuth.storage.set( + UI_STATE_CACHE_KEY, + JSON.stringify({ connected: true, publicKey: 42, liteUsername: null }), + ); + + // When + emitPersistedSessionUiState(); + await flushMicrotasks(); + + // Then: the malformed cache is discarded instead of being laundered into + // a typed session state with non-string fields. + expect(events).toEqual([ + { tag: "Connected", session: { connected: true } }, + ]); + }); + it("As a dotli integrator, the host notifies local and matching storage changes", async () => { // Given const { writeCoreStorage } = createSessionStoreAdapters(); diff --git a/packages/ui/tests/topbar.test.ts b/packages/ui/tests/topbar.test.ts index 1944cefb..0e73a86d 100644 --- a/packages/ui/tests/topbar.test.ts +++ b/packages/ui/tests/topbar.test.ts @@ -457,6 +457,48 @@ describe("topbar login cancellation", () => { "Retry", ); }); + + it("explains statement-store slot exhaustion in the login failure view", async () => { + installTopbarDom(); + const { initTopBar } = await import("@dotli/ui/topbar"); + initTopBar(); + + window.dispatchEvent( + new CustomEvent("dotli:truapi-auth-state", { + detail: { + tag: "LoginFailed", + reason: "no free statement-store slot for device registration", + }, + }), + ); + + const modalText = document.getElementById("auth-modal-qr")?.textContent; + expect(modalText).toContain("No Statement Store slots left"); + expect(modalText).toContain( + "no free statement-store slot for device registration", + ); + expect(modalText).toContain("Retry"); + }); + + it("explains rejected statement-store transactions from the raw reason", async () => { + installTopbarDom(); + const { initTopBar } = await import("@dotli/ui/topbar"); + initTopBar(); + + window.dispatchEvent( + new CustomEvent("dotli:truapi-auth-state", { + detail: { + tag: "LoginFailed", + reason: "submit RPC error: Invalid Transaction", + }, + }), + ); + + const modalText = document.getElementById("auth-modal-qr")?.textContent; + expect(modalText).toContain("Statement Store transaction rejected"); + expect(modalText).toContain("submit RPC error: Invalid Transaction"); + expect(modalText).toContain("Retry"); + }); }); describe("topbar boot rehydration", () => {