diff --git a/bun.lock b/bun.lock index a35c1f97..53945efa 100644 --- a/bun.lock +++ b/bun.lock @@ -294,8 +294,8 @@ "@dotli/storage": "workspace:*", "@dotli/truapi-debug": "workspace:*", "@noble/hashes": "^2.2.0", - "@parity/truapi": "0.10.0", - "@parity/truapi-host": "0.7.0", + "@parity/truapi": "0.12.0", + "@parity/truapi-host": "0.9.0", "@polkadot-api/json-rpc-provider": "^0.2.0", "@scure/base": "^2.2.0", "neverthrow": "^8.2.0", @@ -322,7 +322,7 @@ }, }, "overrides": { - "@parity/truapi": "0.10.0", + "@parity/truapi": "0.12.0", "brace-expansion": "^5.0.9", "deepmerge-ts": "^8.0.1", "esbuild": "^0.28.1", @@ -664,9 +664,9 @@ "@oxc-project/types": ["@oxc-project/types@0.133.0", "", {}, "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA=="], - "@parity/truapi": ["@parity/truapi@0.10.0", "", { "dependencies": { "@noble/hashes": "^2.2.0", "neverthrow": "^8.2.0", "scale-ts": "^1.6.1" } }, "sha512-Kwuvj4jd67NfSB4g+4MVlMGKGFH7HfMXJESscRA0u+KpknsWUE+OV8jzYh1TyAnbSVAIq/sarQK8sSBHvUZKXw=="], + "@parity/truapi": ["@parity/truapi@0.12.0", "", { "dependencies": { "@noble/hashes": "^2.2.0", "neverthrow": "^8.2.0", "scale-ts": "^1.6.1" } }, "sha512-1Aei18qkPWNYgLfmmZ0ETpYHFqjcd2yOJe36jErI/9T1+M19Ovuh35Rma+dTIFYY2rMxVbF/h1D06W9E9RY/2g=="], - "@parity/truapi-host": ["@parity/truapi-host@0.7.0", "", { "dependencies": { "@parity/truapi": "^0.10.0" } }, "sha512-U4BxZw467r1lzBUPt2Emn32JjZ+ZFuBYpoxd8igP0sFnzoxT/YWQ6zbPLL2bCFKjlqxEDd1RsQz5GqM0onuVWA=="], + "@parity/truapi-host": ["@parity/truapi-host@0.9.0", "", { "dependencies": { "@parity/truapi": "^0.12.0" } }, "sha512-BoNkAHf+9xh1qct3+uEh/yHTkO8TF5l+bWwYMaGzuAZdQzodEmWiDbRtVk0aXis97gmAYjkgb2p7W5Bskbh7vw=="], "@playwright/test": ["@playwright/test@1.60.0", "", { "dependencies": { "playwright": "1.60.0" }, "bin": { "playwright": "cli.js" } }, "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag=="], diff --git a/package.json b/package.json index 89a38b83..fe410d50 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,7 @@ "smoldot": "3.4.1" }, "overrides": { - "@parity/truapi": "0.10.0", + "@parity/truapi": "0.12.0", "fast-uri": "3.1.5", "smoldot": "3.4.1", "esbuild": "^0.28.1", diff --git a/packages/ui/package.json b/packages/ui/package.json index 7fdb222c..13e56240 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -33,8 +33,8 @@ "@dotli/storage": "workspace:*", "@dotli/truapi-debug": "workspace:*", "@noble/hashes": "^2.2.0", - "@parity/truapi": "0.10.0", - "@parity/truapi-host": "0.7.0", + "@parity/truapi": "0.12.0", + "@parity/truapi-host": "0.9.0", "@polkadot-api/json-rpc-provider": "^0.2.0", "@scure/base": "^2.2.0", "neverthrow": "^8.2.0", diff --git a/packages/ui/src/host-callbacks/Locale.ts b/packages/ui/src/host-callbacks/Locale.ts new file mode 100644 index 00000000..be3b4bb8 --- /dev/null +++ b/packages/ui/src/host-callbacks/Locale.ts @@ -0,0 +1,23 @@ +import type { LocaleHost } from "@parity/truapi-host"; +import type { HostLocaleSubscribeItem } from "@parity/truapi"; +import { createResultStream } from "./result-stream"; + +// dotli presents English chrome and has no language setting of its own, so the +// visitor's browser preference is the only real signal a product can localize +// against. A product that does not ship the tag picks its own fallback. +function currentLocale(): HostLocaleSubscribeItem { + return { languageTag: navigator.language }; +} + +export function createLocaleSubscribe(): Required["subscribeLocale"] { + return () => + createResultStream([currentLocale()], (push) => { + const onLanguageChanged = (): void => { + push(currentLocale()); + }; + window.addEventListener("languagechange", onLanguageChanged); + return () => { + window.removeEventListener("languagechange", onLanguageChanged); + }; + }); +} diff --git a/packages/ui/src/host-callbacks/SessionStore.ts b/packages/ui/src/host-callbacks/SessionStore.ts index 55c82138..c25547cf 100644 --- a/packages/ui/src/host-callbacks/SessionStore.ts +++ b/packages/ui/src/host-callbacks/SessionStore.ts @@ -271,6 +271,12 @@ function coreLocalStorageKey(key: CoreStorageKey): string { return `${CORE_LOCAL_STORAGE_PREFIX}product-subtree:${hexNoPrefix( encodeCoreStorageKey(key), )}`; + // The ledger bounds replays for one wallet and peer pair, so the whole + // triple has to discriminate the slot. + case "SsoResponderRequestLedger": + return `${CORE_LOCAL_STORAGE_PREFIX}sso-responder-ledger:${hexNoPrefix( + encodeCoreStorageKey(key), + )}`; } } diff --git a/packages/ui/src/host-callbacks/handlers.ts b/packages/ui/src/host-callbacks/handlers.ts index cc87d595..bd6fc945 100644 --- a/packages/ui/src/host-callbacks/handlers.ts +++ b/packages/ui/src/host-callbacks/handlers.ts @@ -21,6 +21,7 @@ import { createChainConnect } from "./Chain"; import { createFeatureSupported } from "./FeatureSupported"; import { createSupportedChains } from "./SupportedChains"; import { createThemeSubscribe } from "./Theme"; +import { createLocaleSubscribe } from "./Locale"; import { createAuthStateChanged } from "./AuthState"; import { createChatPlatform } from "./Chat"; import { createSessionStoreAdapters } from "./SessionStore"; @@ -82,6 +83,7 @@ export function createHostCallbacks( }, userConfirmation: createUserConfirmationAdapters(label, blockingModalScope), theme: { subscribeTheme: createThemeSubscribe() }, + locale: { subscribeLocale: createLocaleSubscribe() }, preimage: createPreimageAdapters(label), chain: { connect: createChainConnect() }, // Always served; the core itself denies chat calls on non-Chat diff --git a/packages/ui/src/runtime-config.ts b/packages/ui/src/runtime-config.ts index 7f97239b..971dd660 100644 --- a/packages/ui/src/runtime-config.ts +++ b/packages/ui/src/runtime-config.ts @@ -20,21 +20,13 @@ function getPlatformType(userAgent: string = navigator.userAgent): string { return "Unknown"; } -// `assetHub` drives the host's dotNS username resolution. Typed as an -// intersection so this compiles against @parity/truapi-host releases from -// before the field existed; once the dependency floor includes it, fold the -// field into the plain ProductRuntimeConfig literal. -type RuntimeConfigWithAssetHub = ProductRuntimeConfig & { - assetHub?: { genesisHash: string | Uint8Array }; -}; - // The window origin deliberately plays no part here: `productId` comes // solely from the label (or the explicit override). export function createTruapiRuntimeConfig( label: string, productId: string = labelToProductId(label), ): ProductRuntimeConfig { - const config: RuntimeConfigWithAssetHub = { + const config: ProductRuntimeConfig = { productId, host: { name: "Polkadot Web", diff --git a/packages/ui/tests/locale.test.ts b/packages/ui/tests/locale.test.ts new file mode 100644 index 00000000..fec7af14 --- /dev/null +++ b/packages/ui/tests/locale.test.ts @@ -0,0 +1,60 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { createLocaleSubscribe } from "@dotli/ui/host-callbacks/Locale"; + +const realLanguage = navigator.language; + +function setBrowserLanguage(tag: string): void { + Object.defineProperty(navigator, "language", { + value: tag, + configurable: true, + }); +} + +afterEach(() => { + setBrowserLanguage(realLanguage); +}); + +describe("locale host callbacks", () => { + it("As a dotli integrator, the host emits the visitor's language immediately", async () => { + // Given + // dotli presents English chrome, but a product localizes itself, so the + // signal has to be what the visitor asked their browser for. + setBrowserLanguage("pt-BR"); + const subscribeLocale = createLocaleSubscribe(); + + // When + const iterator = subscribeLocale()[Symbol.asyncIterator](); + const first = await iterator.next(); + await iterator.return?.(); + + // Then + expect(first.done).toBe(false); + expect(first.value.isOk()).toBe(true); + expect(first.value._unsafeUnwrap()).toEqual({ languageTag: "pt-BR" }); + }); + + it("As a dotli integrator, the host emits language changes until unsubscribed", async () => { + // Given + setBrowserLanguage("en"); + const subscribeLocale = createLocaleSubscribe(); + + const iterator = subscribeLocale()[Symbol.asyncIterator](); + const first = await iterator.next(); + const next = iterator.next(); + + // When + setBrowserLanguage("zh-Hans"); + window.dispatchEvent(new Event("languagechange")); + const changed = await next; + + await iterator.return?.(); + const afterReturn = await iterator.next(); + + // Then + expect(first.value._unsafeUnwrap()).toEqual({ languageTag: "en" }); + expect(changed.done).toBe(false); + expect(changed.value.isOk()).toBe(true); + expect(changed.value._unsafeUnwrap()).toEqual({ languageTag: "zh-Hans" }); + expect(afterReturn.done).toBe(true); + }); +}); diff --git a/packages/ui/tests/session-store.test.ts b/packages/ui/tests/session-store.test.ts index 65eb605e..7b9b4b16 100644 --- a/packages/ui/tests/session-store.test.ts +++ b/packages/ui/tests/session-store.test.ts @@ -300,6 +300,53 @@ describe("session-store host callbacks", () => { ]); }); + it("As a dotli integrator, the host stores the SSO responder replay ledger per wallet and peer", async () => { + // Given + const { readCoreStorage, writeCoreStorage, clearCoreStorage } = + createSessionStoreAdapters(); + const key = { + tag: "SsoResponderRequestLedger", + value: { + rootPublicKey: new Uint8Array(32).fill(1), + peerStatementAccountId: new Uint8Array(32).fill(2), + peerEncryptionPublicKey: new Uint8Array(32).fill(3), + }, + } satisfies CoreStorageKey; + const otherPeer = { + tag: "SsoResponderRequestLedger", + value: { + rootPublicKey: new Uint8Array(32).fill(1), + peerStatementAccountId: new Uint8Array(32).fill(2), + peerEncryptionPublicKey: new Uint8Array(32).fill(4), + }, + } satisfies CoreStorageKey; + + // When + await writeCoreStorage(key, new Uint8Array([21])); + await writeCoreStorage(otherPeer, new Uint8Array([22])); + + // Then + // Core-owned replay state, not key material, so it is stored like the + // ring registry snapshot rather than under at-rest encryption. + expect(localStorage.length).toBe(2); + for (const index of [0, 1]) { + const storageKey = localStorage.key(index); + expect(storageKey).toMatch(/^dotli:core:sso-responder-ledger:[0-9a-f]+$/); + expect(localStorage.getItem(storageKey ?? "")).toMatch(/^0x/); + } + // The ledger bounds replays for one peer, so two peers of the same wallet + // must never share a slot. + expect(Array.from((await readCoreStorage(key)) ?? [])).toEqual([21]); + expect(Array.from((await readCoreStorage(otherPeer)) ?? [])).toEqual([22]); + + // When + await clearCoreStorage(key); + + // Then + expect(await readCoreStorage(key)).toBeUndefined(); + expect(Array.from((await readCoreStorage(otherPeer)) ?? [])).toEqual([22]); + }); + it("As a dotli integrator, the host never reuses a nonce across allowance key writes", async () => { // Given const { readCoreStorage, writeCoreStorage } = createSessionStoreAdapters();