diff --git a/.changeset/rename-provider-to-wireprovider.md b/.changeset/rename-provider-to-wireprovider.md index 7df1bda9..a4b59d9b 100644 --- a/.changeset/rename-provider-to-wireprovider.md +++ b/.changeset/rename-provider-to-wireprovider.md @@ -1,5 +1,5 @@ --- -"@parity/truapi": patch +"@parity/truapi": minor --- Rename the exported `Provider` transport type to `WireProvider` to make its role explicit. It is the low-level SCALE-wire-frame pipe (a `MessagePort` or iframe `postMessage` channel) that `createTransport` runs on. The `createIframeProvider` / `createMessagePortProvider` factories are unchanged; only the type name moves. Consumers importing `Provider` should import `WireProvider` instead. diff --git a/.changeset/truapi-sandbox-bootstrap.md b/.changeset/truapi-sandbox-bootstrap.md index 14eb6333..1b94d436 100644 --- a/.changeset/truapi-sandbox-bootstrap.md +++ b/.changeset/truapi-sandbox-bootstrap.md @@ -1,5 +1,5 @@ --- -"@parity/truapi": patch +"@parity/truapi": minor --- Add the `@parity/truapi/sandbox` entry point: host-environment detection (`isCorrectEnvironment`), a lazily-built cached client (`getClientSync`, `null` outside a host container), and a `subscribeConnectionStatus` connected/disconnected listener. Browser-embedded hosts can bootstrap a client without assembling the transport by hand. diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..4fb9ca56 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +rust/crates/truapi-codegen/tests/golden/* linguist-generated=true +rust/crates/truapi-server/src/generated/* linguist-generated=true diff --git a/.github/workflows/check-rfc.yml b/.github/workflows/check-rfc.yml index 2bfb03d8..35d50852 100644 --- a/.github/workflows/check-rfc.yml +++ b/.github/workflows/check-rfc.yml @@ -14,7 +14,7 @@ jobs: if: github.event.pull_request.user.login != 'github-actions[bot]' runs-on: ubuntu-latest steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: fetch-depth: 0 persist-credentials: false diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f5652e58..7e997639 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,7 +3,7 @@ name: CI on: pull_request: push: - branches: [main, release/v0.3.2] + branches: [main, release/v0.3.0] merge_group: workflow_dispatch: @@ -21,7 +21,7 @@ jobs: env: RUSTFLAGS: "-D warnings" steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: persist-credentials: false @@ -52,7 +52,7 @@ jobs: name: Dependency licenses runs-on: ubuntu-latest steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: persist-credentials: false @@ -65,7 +65,7 @@ jobs: name: Codegen runs-on: ubuntu-latest steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: persist-credentials: false @@ -100,6 +100,7 @@ jobs: js/packages/truapi/src/playground/codegen js/packages/truapi/src/explorer/codegen js/packages/truapi/src/explorer/versions.ts + js/packages/truapi-host-wasm/src/generated playground/test/generated ts-client: @@ -109,7 +110,7 @@ jobs: env: TRUAPI_REQUIRE_GENERATED: 1 steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: persist-credentials: false @@ -142,7 +143,7 @@ jobs: env: TRUAPI_REQUIRE_GENERATED: 1 steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: persist-credentials: false @@ -187,7 +188,7 @@ jobs: env: TRUAPI_REQUIRE_GENERATED: 1 steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: persist-credentials: false @@ -227,7 +228,7 @@ jobs: env: TRUAPI_REQUIRE_GENERATED: 1 steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: submodules: recursive persist-credentials: false diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml index 07aad965..8c1ad37c 100644 --- a/.github/workflows/deploy-docs.yml +++ b/.github/workflows/deploy-docs.yml @@ -20,7 +20,7 @@ jobs: pages: write id-token: write steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: persist-credentials: false diff --git a/.github/workflows/deploy-playground.yml b/.github/workflows/deploy-playground.yml index 83c8a1ad..31f341a1 100644 --- a/.github/workflows/deploy-playground.yml +++ b/.github/workflows/deploy-playground.yml @@ -27,7 +27,7 @@ jobs: deploy-playground: runs-on: ubuntu-latest steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: persist-credentials: false diff --git a/.github/workflows/diagnosis-report.yml b/.github/workflows/diagnosis-report.yml index 386b5027..60e22f2e 100644 --- a/.github/workflows/diagnosis-report.yml +++ b/.github/workflows/diagnosis-report.yml @@ -25,7 +25,7 @@ jobs: if: github.event.label.name == 'diagnosis-report' runs-on: ubuntu-latest steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: persist-credentials: false diff --git a/.github/workflows/number-rfc.yml b/.github/workflows/number-rfc.yml index 8ab1b2ee..08ba5752 100644 --- a/.github/workflows/number-rfc.yml +++ b/.github/workflows/number-rfc.yml @@ -14,7 +14,7 @@ jobs: number-rfc: runs-on: ubuntu-latest steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: fetch-depth: 0 persist-credentials: false diff --git a/.github/workflows/release-version-check.yml b/.github/workflows/release-version-check.yml index 659d13aa..c76e3f60 100644 --- a/.github/workflows/release-version-check.yml +++ b/.github/workflows/release-version-check.yml @@ -12,7 +12,7 @@ jobs: if: startsWith(github.event.pull_request.title, 'release:') runs-on: ubuntu-latest steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: persist-credentials: false diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 835f03fc..245fad64 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -10,7 +10,7 @@ on: # zizmor: ignore[dangerous-triggers] workflow_run: workflows: ["CI"] types: [completed] - branches: [main, release/v0.3.2] + branches: [main, release/v0.3.0] permissions: contents: read @@ -30,7 +30,7 @@ jobs: permissions: contents: write steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: fetch-depth: 0 ref: ${{ github.event.workflow_run.head_sha }} @@ -80,13 +80,11 @@ jobs: - name: Tag release if: steps.version.outputs.proceed == 'true' + run: | + git tag "${STEPS_VERSION_OUTPUTS_TAG}" + git push origin "${STEPS_VERSION_OUTPUTS_TAG}" env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} STEPS_VERSION_OUTPUTS_TAG: ${{ steps.version.outputs.tag }} - run: | - gh api repos/${{ github.repository }}/git/refs \ - -f ref="refs/tags/${STEPS_VERSION_OUTPUTS_TAG}" \ - -f sha="${{ github.event.workflow_run.head_sha }}" - name: Pack package if: steps.version.outputs.proceed == 'true' diff --git a/.gitignore b/.gitignore index 9138e1a8..620568d2 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,12 @@ lerna-debug.log* node_modules target +# Gradle (Android workspace at repo root) +/.gradle/ +/build/ +/android/*/build/ +local.properties + # Environment / secrets (never commit real env files; keep example templates) .env .env.* @@ -39,3 +45,16 @@ playground/public/static.files # Auto-generated by truapi-codegen (typecheck fixtures for rustdoc ts blocks) playground/test/generated/ + +# Auto-generated FFI / WASM binding outputs +android/truapi-host/src/main/kotlin/generated/ +ios/truapi-host/Sources/TrUAPIHost/truapi_server.swift +ios/truapi-host/Sources/truapi_serverFFI/ +rust/crates/truapi-server/pkg/ +js/packages/truapi/src/generated/ +js/packages/truapi/dist/generated/ +js/packages/truapi-host/src/generated/ +js/packages/truapi-host/dist/generated/ +js/packages/truapi-host-wasm/src/generated/ +js/packages/truapi-host-wasm/dist/generated/ +js/packages/truapi-host-wasm/dist/wasm/ diff --git a/.prettierrc b/.prettierrc index 8224a16a..7d4a0046 100644 --- a/.prettierrc +++ b/.prettierrc @@ -10,7 +10,10 @@ "endOfLine": "lf", "overrides": [ { - "files": "js/packages/truapi/src/**/*.test.ts", + "files": [ + "js/packages/truapi/src/**/*.test.ts", + "js/packages/truapi-host-wasm/src/**/*.test.ts" + ], "options": { "tabWidth": 4, "printWidth": 100 diff --git a/CLAUDE.md b/CLAUDE.md index 0116c828..4ad2ef6e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,17 +8,38 @@ This repo is the single source of truth for the TrUAPI protocol. It vendors `dot ``` rust/crates/ - truapi/ Rust trait + type definitions for protocol versions v0.1 and v0.2 + truapi/ Rust trait + type definitions for protocol versions v0.1 and v0.2 (canonical) truapi-codegen/ rustdoc JSON → TypeScript client + Rust dispatcher truapi-macros/ #[wire(id = N)] proc-macro + truapi-platform/ Host syscall traits (storage, navigation, consent, ...) + truapi-server/ Rust runtime hosts implement; ships as WASM (browser/node) js/packages/ - truapi/ @parity/truapi TS package; generated TS lives under ignored paths -playground/ Next.js interactive playground; deploys to truapi-playground.dot -hosts/dotli/ dotli submodule -docs/ design docs, RFCs, feature proposals -scripts/codegen.sh regenerate the TS client from the Rust crate + truapi/ @parity/truapi TS package; generated TS lives under ignored paths + truapi-host-wasm/ @parity/truapi-host-wasm: WASM-backed host runtime. Subpath entries: + `.` (shared host types), `/web` (iframe + Web + Worker), `/worker-runtime` (Worker entry). + WASM bundle (gitignored) under dist/wasm/web/, built via `make wasm` +playground/ Next.js interactive playground; deploys to truapi-playground.dot +hosts/dotli/ dotli submodule +docs/ design docs, RFCs, feature proposals +scripts/codegen.sh regenerate the TS client from the Rust crate ``` +### Crate + binding invariants + +- `truapi` is canonical; runtime crates re-export rather than redefine. New + syscall traits and host-side runtime types live in `truapi-platform` and + `truapi-server`, not in `truapi`. Any additions to `truapi` itself are limited + to additive `Display` impls. +- All types exposed by `truapi-platform` and `truapi-server` come from + `truapi::versioned::*` and `truapi::v01::*`. The runtime crates re-export + rather than redefine. +- `truapi-server` WASM artifacts live under + `js/packages/truapi-host-wasm/dist/wasm/web/` and are gitignored. + Build them locally with `make wasm` (rerun whenever + `rust/crates/truapi-server/` changes); CI builds the bundle fresh from the + Rust source on every run. + ## Code style - Every `pub` Rust item (functions, methods, types, traits, modules, constants) carries a doc comment (`///` or `//!`). @@ -26,12 +47,28 @@ scripts/codegen.sh regenerate the TS client from the Rust crate - Do not add code comments or doc comments that narrate migrations, compatibility shims, or historical changes. Comments should describe only the current code. - Remove legacy compatibility code by default. Keep or add it only when explicitly requested. - In Rust format strings, prefer inlined variables: `"log value: {value:?}"` over `"log value: {:?}", value`. +- For Rust modules, prefer `foo.rs` plus an optional `foo/` directory for + child modules. Do not introduce new `foo/mod.rs` files unless preserving + generated output or an existing external convention. +- In runtime Rust code, prefer `core::` over `std::` for types that are + available in `core` (`core::pin::Pin`, `core::task::Poll`, `core::fmt`, and + similar). Keep `std::` for std-only APIs, tests, and std-only programs such + as `truapi-codegen`. - **No `any` in TypeScript types**: If a type can't be expressed cleanly, stop and ask the user whether to (a) refactor or import the right type or (b) add a scoped `// eslint-disable-next-line @typescript-eslint/no-explicit-any` exception. Never silently leave `any`. - Don't introduce typealias chains that just rename a public type from another crate (e.g. `pub type StorageError = crate::v01::HostLocalStorageReadError`). Use the canonical name directly. A typealias is only worth its indirection when it captures a real abstraction. -- After any code change, update `README.md` (and CLAUDE.md if the layout changed) so the top-level docs reflect what the repo actually contains. Stale docs are a regression. +- After any code change, update `README.md` (and CLAUDE.md if the layout changed) so the top-level docs reflect what the repo actually contains. Stale docs are a regression. When moving or removing docs, `rg` for the old path and update or remove stale links in README files, agent notes, skills, comments, and design docs. - In codegen emitters, prefer `indoc::writedoc!` / `formatdoc!` over chains of `writeln!`. A single `writedoc!` with a multi-line raw string keeps the emitted shape visible in source instead of fragmenting it across one-line `writeln!` calls. Reserve `writeln!` for the genuinely-one-line case (a single import, a single statement inside a loop). - In PR descriptions, issue comments, and other artifacts that outlive the conversation: describe the resulting state, not the transition between commits. Avoid "previously X, now Y", "we removed", "the old shim is gone", "this PR replaces", those read as ephemeral history once the PR is squash-merged. Write what the system _does_ after the change, not what each commit _changed_ on the way there. (Commit messages are the place for transition narrative; they survive in `git log` even after the squash.) +## Explanation style + +- For architecture, event-flow, and debugging explanations, start with a short + direct summary of the model before diving into long details. Prefer simple + statements like "the host sends a dirty signal; the core re-reads and derives + auth state" before listing each hop. +- Use diagrams only when they clarify ownership or message flow. Keep them + layered and label what is per-tab, shared, host-owned, and core-owned. + ## First-time setup ```bash @@ -116,6 +153,90 @@ submodule init + `bun install` and the per-pane `cd` discipline). Alternatively, with a deployed Polkadot Desktop Host installed, navigate to `https://dot.li/localhost:3000` from within it. +#### Local dotli + playground E2E notes + +Use `make dev DEBUG=1` from the repo root for the local host stack. It prepares +the ignored WASM/build artifacts, verifies dotli can resolve +`@parity/truapi-host-wasm`, then starts dotli on `:5173` and the playground on +`:3000`. Open `http://localhost:5173/localhost:3000`. + +When automating with Playwright, block service workers for smoke tests unless +the test is explicitly about SW behavior. Stale host/product bundles can mask +runtime fixes. Use a fresh cache-busting query string on +`http://localhost:5173/localhost:3000?...`, collect `pageerror` and +`console` messages, and fail on unexpected page errors. + +For interactive SSO checks, prefer a persistent headed Chrome profile and reuse +the same browser context across checks. SSO pairing needs a real phone QR scan, +and signing/resource-allocation flows may need web or mobile confirmation; if +the human or companion app is unavailable, skip those methods and record the +skip instead of treating it as a protocol failure. Non-interactive checks should +still verify that the playground renders, the TrUAPI debug panel receives +host/product events, generated examples can call non-confirmation methods, and +logout/relogin does not restore a stale session. + +The dotli Playwright e2e suite under `hosts/dotli/apps/host/tests/e2e/` +pairs through the signer-bot service. It requires `SIGNER_BOT_SVC_TOKEN`; +`SIGNER_BOT_BASE_URL` and `SIGNER_BOT_NETWORK` default to dotli CI's +`https://signing-bot-dev.novasama-tech.org/` and `paseo-next-v2`. Without the +token, do not treat the full suite as locally runnable. Use +`E2E_DOTLI_SMOKE=1 make e2e-dotli` for the no-phone QR smoke path. +If those signer-bot variables are not available in a worktree, check for a +repo-root `.env` and load or copy the values from there before falling back to +smoke mode. Prefer the current worktree's `.env` when it exists. + +For a fully automated local playground diagnosis run, use: + +```bash +SIGNER_BOT_SVC_TOKEN=... \ +make e2e-dotli +``` + +`make e2e-dotli` starts dotli preview and the playground, signs out any +restored host session, signs in through signer-bot by extracting the QR payload, +runs the playground Diagnosis screen, auto-accepts host-side Allow/Sign modals, +and writes `hosts/dotli/test-results/e2e-dotli/diagnosis-report.md`. + +Root CI runs the same target when it can read the private dotli submodule. It +needs `DOTLI_CHECKOUT_TOKEN` for submodule checkout; without that token, the +job warns and skips dotli e2e rather than failing unrelated PR checks. With +dotli access but without `SIGNER_BOT_SVC_TOKEN`, CI runs the no-phone smoke +path only. + +A useful no-phone smoke assertion is: + +```bash +E2E_DOTLI_SMOKE=1 make e2e-dotli +``` + +For manual debugging of that smoke path: + +1. Start `make dev DEBUG=1`. +2. Open `http://localhost:5173/localhost:3000?debug=truapi&cachebust=` with + service workers blocked. +3. Wait for `globalThis.__truapi?.setLogLevel`, call + `__truapi.setLogLevel("debug")`, and confirm the console logs + `[truapi worker] logLevel=debug providers=0`. +4. Click `#auth-button`, wait for `#auth-modal-backdrop.open`, and confirm: + the modal shows `Login with Polkadot Mobile`, `__truapi.getProviderCount()` + is greater than zero, worker frame/callback logs appear, and there are no + page errors. + +If `make dev` reports `EADDRINUSE` on `:5173` or the playground moves from +`:3000` to `:3001`, kill stale `preview-server.ts` / `next dev` processes and +restart the tmux session. Port drift causes false-negative local e2e results. + +Useful debug signals: + +```js +__truapi.setLogLevel("debug"); +sessionStorage.setItem("dotli:truapi-debug", "1"); +``` + +Reload after setting the debug-panel flag. Watch for `Unknown wire discriminant`, missing +`@parity/truapi-host-wasm` imports, worker WASM instantiation failures, and +debug-panel traffic disappearing when the login popup opens. + ## Deployment Pushes to `main` trigger `.github/workflows/deploy-playground.yml`, which builds `playground/` and publishes the static export to `truapi-playground.dot` via `bulletin-deploy`. diff --git a/Cargo.lock b/Cargo.lock index 4b514a60..5004eda9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3307,6 +3307,7 @@ dependencies = [ "wasm-bindgen-test", "web-sys", "web-time", + "zeroize", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index d657d267..042aff82 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,6 @@ [workspace] resolver = "2" members = ["rust/crates/*"] -exclude = ["rust/crates/truapi-server"] [workspace.package] edition = "2024" diff --git a/Makefile b/Makefile index c8bc371c..285aa467 100644 --- a/Makefile +++ b/Makefile @@ -3,39 +3,65 @@ # Run `make help` for the list of targets. .DEFAULT_GOAL := help -.PHONY: help setup build codegen test check playground dev matrix explorer +.PHONY: help setup build codegen test check playground wasm wasm-crypto-test dev dev-bootstrap dev-link-check e2e-dotli matrix explorer TRUAPI_PKG := js/packages/truapi PLAYGROUND := playground +JS_PACKAGES := js/packages EXPLORER := explorer DOTLI := hosts/dotli +HOST_WASM_PKG := $(JS_PACKAGES)/truapi-host-wasm +HOST_CALLBACKS_GENERATED := $(HOST_WASM_PKG)/src/generated/host-callbacks.ts +HOST_WASM_ADAPTER_GENERATED := $(HOST_WASM_PKG)/src/generated/host-callbacks-adapter.ts +HOST_WASM_WORKER_CALLBACKS_GENERATED := $(HOST_WASM_PKG)/src/generated/worker-callbacks.ts +HOST_WASM_WEB := $(HOST_WASM_PKG)/dist/wasm/web/truapi_server.js +DOTLI_UI := $(DOTLI)/packages/ui +DOTLI_HOST_WASM_LINK := $(DOTLI_UI)/node_modules/@parity/truapi-host-wasm +SIGNER_BOT_BASE_URL ?= https://signing-bot-dev.novasama-tech.org/ +SIGNER_BOT_NETWORK ?= paseo-next-v2 +VITE_NETWORKS ?= paseo-next-v2,previewnet +export SIGNER_BOT_BASE_URL +export SIGNER_BOT_NETWORK +export VITE_NETWORKS -# `make dev DEBUG=1` runs dotli with VITE_APP_DEBUG=true to log every wire frame. -DOTLI_PREVIEW := preview -ifdef DEBUG -DOTLI_PREVIEW := preview:debug -endif +# Local product URLs (`http://localhost:5173/localhost:3000`) are intentionally +# gated behind dotli's debug build flag, so the dev target must run the debug +# preview by default. Override with `DOTLI_PREVIEW=preview` to test production +# preview behavior. +DOTLI_PREVIEW ?= preview:debug help: ## Show this help. @awk 'BEGIN { FS = ":.*##"; printf "Usage: make \n\nTargets:\n" } \ - /^[a-zA-Z_-]+:.*?##/ { printf " %-12s %s\n", $$1, $$2 }' $(MAKEFILE_LIST) + /^[a-zA-Z0-9_-]+:.*?##/ { printf " %-12s %s\n", $$1, $$2 }' $(MAKEFILE_LIST) -setup: ## First-time setup: submodules + JS dependencies. +setup: ## First-time setup: submodules, JS dependencies, generated artifacts. git submodule update --init --recursive - cd $(TRUAPI_PKG) && npm install + # --ignore-scripts: the workspace `prepare` builds need generated sources + # that only exist after codegen.sh, which also builds the packages. + npm ci --ignore-scripts + ./scripts/codegen.sh cd $(PLAYGROUND) && yarn install --frozen-lockfile + cd $(DOTLI) && bun install --frozen-lockfile build: ## Build the Rust workspace and the TypeScript client. cargo build --workspace cd $(TRUAPI_PKG) && npm run build + cd $(HOST_WASM_PKG) && npm run build -codegen: ## Regenerate the TypeScript client from the Rust crate. +codegen: ## Regenerate generated TS/Rust artifacts from the Rust crates. ./scripts/codegen.sh cd $(PLAYGROUND) && rm -rf node_modules/@parity && yarn install +wasm: ## Rebuild the truapi-server WASM artifacts under js/packages/truapi-host-wasm/dist/wasm/. + cd $(HOST_WASM_PKG) && npm run build:wasm + +wasm-crypto-test: ## Run crypto/vector tests on wasm32 via wasm-pack/node. + wasm-pack test --node rust/crates/truapi-server --test wasm_crypto_vectors --no-default-features + test: ## Run Rust + TypeScript client tests. cargo test --workspace cd $(TRUAPI_PKG) && npm test + cd $(JS_PACKAGES)/truapi-host-wasm && npm test check: ## Full verification suite (build, fmt, clippy, test, TS tests, playground build + lint). cargo build --workspace @@ -43,6 +69,7 @@ check: ## Full verification suite (build, fmt, clippy, test, TS tests, playgroun cargo clippy --workspace --all-targets --all-features -- -D warnings cargo test --workspace cd $(TRUAPI_PKG) && npm run build && npm test + cd $(JS_PACKAGES)/truapi-host-wasm && npm install --no-fund --no-audit && npm test cd $(PLAYGROUND) && yarn build && yarn lint playground: ## Refresh the playground's @parity/truapi snapshot and rebuild. @@ -50,12 +77,48 @@ playground: ## Refresh the playground's @parity/truapi snapshot and rebuild. cd $(PLAYGROUND) && rm -rf node_modules/@parity && yarn install cd $(PLAYGROUND) && yarn build -dev: ## Start dotli host (:5173) + playground (:3000) together; open http://localhost:5173/localhost:3000. DEBUG=1 logs wire frames. +dev-bootstrap: ## Prepare ignored generated/build artifacts needed by dotli preview. + git submodule update --init --recursive + # --ignore-scripts: the workspace `prepare` builds need generated sources + # that only exist after codegen.sh, which also builds the packages. + if [ ! -d node_modules ]; then npm ci --ignore-scripts; fi + if [ ! -f "$(HOST_CALLBACKS_GENERATED)" ] || [ ! -f "$(HOST_WASM_ADAPTER_GENERATED)" ] || [ ! -f "$(HOST_WASM_WORKER_CALLBACKS_GENERATED)" ]; then ./scripts/codegen.sh; fi + cd $(HOST_WASM_PKG) && npm run build + TRUAPI_WASM_PROFILE=dev $(MAKE) wasm + cd $(PLAYGROUND) && yarn install --frozen-lockfile + cd $(DOTLI) && bun install --frozen-lockfile + $(MAKE) dev-link-check + +dev-link-check: ## Verify dotli can resolve the local @parity/truapi-host-wasm package. + @test -f "$(HOST_CALLBACKS_GENERATED)" || (echo "Missing generated host callbacks. Run: make codegen"; exit 1) + @test -f "$(HOST_WASM_ADAPTER_GENERATED)" || (echo "Missing generated host callbacks WASM adapter. Run: make codegen"; exit 1) + @test -f "$(HOST_WASM_WORKER_CALLBACKS_GENERATED)" || (echo "Missing generated host callbacks worker bridge. Run: make codegen"; exit 1) + @test -f "$(HOST_WASM_PKG)/dist/index.js" || (echo "Missing @parity/truapi-host-wasm dist. Run: npm run build --prefix $(HOST_WASM_PKG)"; exit 1) + @test -f "$(HOST_WASM_WEB)" || (echo "Missing @parity/truapi-host-wasm web WASM glue. Run: make wasm"; exit 1) + @test -e "$(DOTLI_HOST_WASM_LINK)/package.json" || (echo "dotli cannot resolve @parity/truapi-host-wasm. Run top-level: make dev"; exit 1) + cd $(DOTLI_UI) && bun -e 'await import("@parity/truapi-host-wasm"); await import("@parity/truapi-host-wasm/web");' + +dev: dev-bootstrap ## Start dotli host (:5173) + playground (:3000) together; open http://localhost:5173/localhost:3000. DEBUG=1 logs wire frames. @trap 'kill 0' EXIT; \ ( cd $(DOTLI) && bun run $(DOTLI_PREVIEW) ) & \ ( cd $(PLAYGROUND) && yarn dev ) & \ + ( until curl -fsS http://localhost:3000/ >/dev/null 2>&1; do sleep 1; done; curl -fsS http://localhost:3000/diagnostics >/dev/null 2>&1 || true ) & \ wait +e2e-dotli: ## Fully automated dotli + playground diagnosis e2e. Requires SIGNER_BOT_SVC_TOKEN unless E2E_DOTLI_SMOKE=1. + @SIGNER_BOT_SVC_TOKEN_ENV="$$SIGNER_BOT_SVC_TOKEN"; \ + SIGNER_BOT_BASE_URL_ENV="$$SIGNER_BOT_BASE_URL"; \ + SIGNER_BOT_NETWORK_ENV="$$SIGNER_BOT_NETWORK"; \ + set -a; \ + if [ -f .env ]; then . ./.env; fi; \ + set +a; \ + if [ -n "$$SIGNER_BOT_SVC_TOKEN_ENV" ]; then SIGNER_BOT_SVC_TOKEN="$$SIGNER_BOT_SVC_TOKEN_ENV"; export SIGNER_BOT_SVC_TOKEN; fi; \ + if [ -n "$$SIGNER_BOT_BASE_URL_ENV" ]; then SIGNER_BOT_BASE_URL="$$SIGNER_BOT_BASE_URL_ENV"; export SIGNER_BOT_BASE_URL; fi; \ + if [ -n "$$SIGNER_BOT_NETWORK_ENV" ]; then SIGNER_BOT_NETWORK="$$SIGNER_BOT_NETWORK_ENV"; export SIGNER_BOT_NETWORK; fi; \ + if [ "$$E2E_DOTLI_SMOKE" != "1" ]; then test -n "$$SIGNER_BOT_SVC_TOKEN" || (echo "Missing SIGNER_BOT_SVC_TOKEN. e2e-dotli requires signer-bot; without it a human phone scan is required."; exit 1); fi; \ + $(MAKE) dev-bootstrap; \ + cd $(DOTLI)/apps/host && bun tests/e2e/playground-diagnosis.ts + matrix: ## Regenerate the host compatibility matrix from explorer/diagnosis-reports. cd $(EXPLORER) && npm run generate-matrix diff --git a/README.md b/README.md index a08d9483..f8abf3c9 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ > The following is a prototype, reference implementation, and proof-of-concept. This open source code is provided for research, experimentation, and developer education only. This code has not been audited, is actively experimental, and may contain bugs, vulnerabilities, or incomplete features. Use at your own risk. -*The protocol that lets product webviews talk to their Polkadot host.* +_The protocol that lets product webviews talk to their Polkadot host._ [![License](https://img.shields.io/badge/license-MIT-blue.svg?style=flat-square)](./LICENSE) [![CI](https://img.shields.io/github/actions/workflow/status/paritytech/truapi/ci.yml?branch=main&style=flat-square&label=ci)](https://github.com/paritytech/truapi/actions/workflows/ci.yml) @@ -57,14 +57,30 @@ rust/crates/ truapi/ Rust trait and type definitions (v01, v02) truapi-codegen/ rustdoc JSON to TypeScript client + Rust dispatcher truapi-macros/ #[wire(id = N)] proc-macro + truapi-platform/ Host syscall traits used by truapi-server (storage, navigation, consent, ...) + truapi-server/ Rust runtime that hosts implement: dispatcher, frames, SCALE, WASM surface js/packages/ - truapi/ @parity/truapi TypeScript client -playground/ Interactive Next.js playground (truapi-playground.dot) -hosts/dotli/ dotli host, vendored as a submodule -docs/ Design docs, RFCs, feature proposals -scripts/codegen.sh Regenerate the TS client from the Rust source + truapi/ @parity/truapi TypeScript client + truapi-host-wasm/ @parity/truapi-host-wasm: WASM-backed host runtime; entries `.` + (shared host types), `/web` (iframe + Web Worker), + `/worker-runtime` +playground/ Interactive Next.js playground (truapi-playground.dot) +hosts/dotli/ dotli host, vendored as a submodule +docs/ Design docs, RFCs, feature proposals +scripts/codegen.sh Regenerate the TS client from the Rust source ``` +### JS Host SDKs + +JS hosts integrate the Rust core through [`@parity/truapi-host-wasm`](js/packages/truapi-host-wasm), +a single package with tree-shakeable subpath entries: + +- `@parity/truapi-host-wasm` (the `.` entry) exposes shared host runtime types and generated callback contracts. +- `@parity/truapi-host-wasm/web` wires the WASM provider into a browser host: the iframe + MessageChannel handshake (`createIframeHost`) plus `createWebWorkerProvider`. +- `@parity/truapi-host-wasm/worker-runtime` is the Web Worker entrypoint so the WASM core can + run off the page main thread. + ## How it works 1. The protocol is defined as Rust traits in [`rust/crates/truapi/`](rust/crates/truapi/), with each method tagged `#[wire(id = N)]` for a stable byte-level dispatch table. Every method's doc comment must carry a ` ```ts ` example, which codegen extracts into the playground's EXAMPLE tab; the build fails if any method is missing one. @@ -80,9 +96,10 @@ Common tasks are wrapped in the top-level `Makefile`. Run `make help` for the fu ```bash make setup # submodules + JS dependencies -make build # Rust workspace + TypeScript client -make test # Rust + TypeScript client tests +make build # Rust workspace + TypeScript client + @parity/truapi-host-wasm +make test # Rust + TypeScript client + @parity/truapi-host-wasm tests make check # full suite: build, fmt, clippy, test, TS tests, playground build + lint +make wasm # rebuild truapi-server WASM artifacts under js/packages/truapi-host-wasm/dist/wasm/ ``` To run the playground locally: @@ -129,4 +146,3 @@ See [`CONTRIBUTING.md`](CONTRIBUTING.md) for issue reports, feature proposals, a ## License [MIT](./LICENSE) - diff --git a/deny.toml b/deny.toml index 4fdc03c6..9305a03f 100644 --- a/deny.toml +++ b/deny.toml @@ -5,6 +5,7 @@ allow = [ "MIT", "Apache-2.0", + "Apache-2.0 WITH LLVM-exception", "BSD-2-Clause", "BSD-3-Clause", "CC0-1.0", @@ -13,3 +14,17 @@ allow = [ "Zlib", ] confidence-threshold = 0.8 + +# uniffi is MPL-2.0: file-level weak copyleft, consumed unmodified as a +# dependency, which MPL-2.0 permits without affecting the MIT outbound licence. +# Scoped per crate so MPL-2.0 stays disallowed everywhere else. +exceptions = [ + { name = "uniffi", allow = ["MPL-2.0"] }, + { name = "uniffi_bindgen", allow = ["MPL-2.0"] }, + { name = "uniffi_core", allow = ["MPL-2.0"] }, + { name = "uniffi_internal_macros", allow = ["MPL-2.0"] }, + { name = "uniffi_macros", allow = ["MPL-2.0"] }, + { name = "uniffi_meta", allow = ["MPL-2.0"] }, + { name = "uniffi_pipeline", allow = ["MPL-2.0"] }, + { name = "uniffi_udl", allow = ["MPL-2.0"] }, +] diff --git a/docs/local-e2e-testing.md b/docs/local-e2e-testing.md index f646e2db..78e0602a 100644 --- a/docs/local-e2e-testing.md +++ b/docs/local-e2e-testing.md @@ -25,6 +25,21 @@ The chain below is also automated: The doc below is still the canonical narrative and the source of truth for failure modes — both the skills and CI cite it. +`make e2e-dotli` is the end-to-end dotli + playground diagnosis harness. It +starts the local dotli preview and playground, opens Chromium, signs out any +restored host session, signs in through the signer-bot SSO service, runs the +playground Diagnosis screen, and writes +`hosts/dotli/test-results/e2e-dotli/diagnosis-report.md`. Full automation +requires `SIGNER_BOT_SVC_TOKEN`; `SIGNER_BOT_BASE_URL` and +`SIGNER_BOT_NETWORK` default to dotli CI's signer-bot service and +`paseo-next-v2`. Without the token, use +`E2E_DOTLI_SMOKE=1 make e2e-dotli` to verify the local stack, browser launch, +login click, TrUAPI debug logs, and QR/deeplink extraction without a phone. +In root CI, the job also needs `DOTLI_CHECKOUT_TOKEN` to read the private +dotli submodule. Without dotli access it reports a warning and skips the e2e +job; with dotli access but without `SIGNER_BOT_SVC_TOKEN`, it runs the smoke +path only. + The order matters: each layer assumes the layer below it builds clean. Skip a step only if you are certain the change cannot affect that layer. @@ -164,12 +179,19 @@ method from the UI. ```bash cd hosts/dotli bun run preview # → http://localhost:5173 -# or, for the TrUAPI debug panel: -bun run preview:debugger # = VITE_APP_DEBUG=true bun run preview ``` -`preview:debugger` is recommended whenever you're investigating a wire -issue — the debug panel logs every host↔product TrUAPI frame. +When investigating a wire issue, raise the Rust core's log level from the +host origin. The WASM worker bridge forwards core `tracing` output to the +browser console, mapping each level to the matching `console` method: + +```js +window.__truapi.setLogLevel("trace"); +``` + +`debug` and `trace` are emitted via `console.debug`, which Chrome hides +unless the console **Default levels ▾** dropdown includes **Verbose**; +`info`/`warn`/`error` always render. ### Start the playground dev server @@ -225,9 +247,9 @@ failing. Check: stale; redo step 4). If a method call hangs, the host either didn't receive the frame -(check dotli's debug panel or console) or didn't respond. The bridge -auto-responds to `host_handshake_request` only; everything else is on -the host implementation. +(check dotli's console with `truapi:logLevel` set to `debug`) or didn't respond. +The bridge auto-responds to `host_handshake_request` only; everything +else is on the host implementation. ## 7. Codegen tests diff --git a/hosts/dotli b/hosts/dotli index 80bedceb..a347be44 160000 --- a/hosts/dotli +++ b/hosts/dotli @@ -1 +1 @@ -Subproject commit 80bedceb98bd6f305f5bf134c0225c203752ecac +Subproject commit a347be447acae3729f5857a12e814433efa3b4d0 diff --git a/js/packages/truapi-host-wasm/.gitignore b/js/packages/truapi-host-wasm/.gitignore new file mode 100644 index 00000000..288deac9 --- /dev/null +++ b/js/packages/truapi-host-wasm/.gitignore @@ -0,0 +1,11 @@ +node_modules/ +*.tsbuildinfo +# Ignore compiled TS output (top-level + the web/ and electron/ entry subdirs) +# Generated WASM artifacts under dist/wasm/ are ignored by the repo root. +dist/**/*.js +dist/**/*.d.ts +dist/**/*.js.map +dist/**/*.d.ts.map +dist/generated/ +# Codegen output from truapi-codegen --platform-ts-output. +src/generated/ diff --git a/js/packages/truapi-host-wasm/README.md b/js/packages/truapi-host-wasm/README.md new file mode 100644 index 00000000..2fb2847c --- /dev/null +++ b/js/packages/truapi-host-wasm/README.md @@ -0,0 +1,72 @@ +# @parity/truapi-host-wasm + +WASM-backed TrUAPI host runtime. It embeds the `truapi-server` Rust core (compiled to WASM) +behind a Web Worker provider, plus per-environment integration entry points. It is the +counterpart to the native Android/iOS host shells. + +## Entry points + +The package exposes tree-shakeable subpath exports — import only what your environment needs: + +| Import | Provides | +| ----------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | +| `@parity/truapi-host-wasm` | Shared runtime types plus generated typed host callback contracts. | +| `@parity/truapi-host-wasm/web` | Browser pairing host: `createIframeHost` (iframe MessageChannel handshake) and `createWebWorkerPairingHostRuntime`. | +| `@parity/truapi-host-wasm/worker-runtime` | Web Worker entrypoint (import with your bundler's `?worker` suffix) so the WASM core runs off the page main thread. | +| `@parity/truapi-host-wasm/wasm/web` | The raw browser `wasm-bindgen` glue, if you need to instantiate the core yourself. | + +## Generated WASM artefacts + +The ignored bundle under `dist/wasm/web/` is built with host-owned chain access. +Hosts wire their JSON-RPC provider through `chainConnect`; if they omit it, +chain calls fail with the core's standard unavailable error. The bundled WASM is +about 1 MB (release build with `wasm-opt`). + +Build them after editing `rust/crates/truapi-server` and before packaging, publishing, or running +tests that load the raw WASM bundle (requires `wasm-pack` on PATH): + +```bash +npm run build:wasm # or `make wasm` from the repo root +``` + +## Example — browser (Web Worker) + +```ts +import HostWorker from "@parity/truapi-host-wasm/worker-runtime?worker"; +import { createWebWorkerPairingHostRuntime } from "@parity/truapi-host-wasm/web"; + +const runtime = await createWebWorkerPairingHostRuntime(new HostWorker(), callbacks, { + hostConfig, +}); + +const firstProvider = await runtime.createProvider({ productId: "first.dot" }); +const secondProvider = await runtime.createProvider({ + productId: "second.dot", +}); +``` + +`@parity/truapi-host-wasm/web` also exports `createIframeHost` for the +protocol-iframe MessageChannel handshake. Host code creates one worker runtime +and then opens one provider per product id. + +## Publishing + +The npm publish workflow is not wired yet. A release-process discussion is needed before adding a +publish job to `.github/workflows/`. Until then, consumers depend on the package via the workspace +`file:` link or by publishing locally with `npm pack`. + +## Architecture + +```text +JS host code + protocol handlers / typed callbacks + (types from @parity/truapi-host-wasm) + | + v +createWebWorkerPairingHostRuntime + shared worker runtime: pairing session, chain runtime, WASM instance + | + +-- createProvider({ productId }) -> product core / WireProvider + | + +-- createProvider({ productId }) -> product core / WireProvider +``` diff --git a/js/packages/truapi-host-wasm/package.json b/js/packages/truapi-host-wasm/package.json new file mode 100644 index 00000000..c9125f87 --- /dev/null +++ b/js/packages/truapi-host-wasm/package.json @@ -0,0 +1,49 @@ +{ + "name": "@parity/truapi-host-wasm", + "version": "0.1.0", + "description": "WASM-backed TrUAPI host runtime: embeds the Rust core, with web iframe and Web Worker entry points", + "license": "MIT", + "author": "Parity Technologies ", + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "sideEffects": [ + "./dist/worker-runtime.js", + "./dist/wasm/**" + ], + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "./web": { + "types": "./dist/web/index.d.ts", + "import": "./dist/web/index.js" + }, + "./worker-runtime": { + "types": "./dist/worker-runtime.d.ts", + "import": "./dist/worker-runtime.js" + }, + "./wasm/web": { + "types": "./dist/wasm/web/truapi_server.d.ts", + "import": "./dist/wasm/web/truapi_server.js" + } + }, + "files": [ + "dist", + "README.md" + ], + "scripts": { + "build": "tsc -b", + "build:wasm": "node scripts/build-wasm.mjs", + "test": "bun test" + }, + "dependencies": { + "@parity/truapi": "file:../truapi" + }, + "devDependencies": { + "@types/bun": "^1.3.0", + "neverthrow": "^8.2.0", + "typescript": "^5.7" + } +} diff --git a/js/packages/truapi-host-wasm/scripts/build-wasm.mjs b/js/packages/truapi-host-wasm/scripts/build-wasm.mjs new file mode 100644 index 00000000..97583e4a --- /dev/null +++ b/js/packages/truapi-host-wasm/scripts/build-wasm.mjs @@ -0,0 +1,63 @@ +#!/usr/bin/env node +// Rebuild the browser truapi-server WASM artefacts generated under +// `dist/wasm/web/`. wasm-pack is required. + +import { execFile } from "node:child_process"; +import { rm } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); +const __dirname = dirname(fileURLToPath(import.meta.url)); +const pkgRoot = resolve(__dirname, ".."); +const repoRoot = resolve(pkgRoot, "../../.."); +const rustCrate = resolve(repoRoot, "rust/crates/truapi-server"); +const wasmProfile = process.env.TRUAPI_WASM_PROFILE ?? "release"; + +function args(target, outDir) { + const command = [ + "build", + "--target", + target, + "--out-dir", + outDir, + "--out-name", + "truapi_server", + ]; + if (wasmProfile === "dev") { + command.push("--dev"); + } else if (wasmProfile === "profiling") { + command.push("--profiling"); + } else if (wasmProfile !== "release") { + throw new Error( + `Unsupported TRUAPI_WASM_PROFILE=${wasmProfile}; expected release, dev, or profiling`, + ); + } + command.push(rustCrate, "--no-default-features"); + return command; +} + +async function build(target, subdir) { + const outDir = resolve(pkgRoot, "dist/wasm", subdir); + process.stdout.write( + `wasm-pack build --target ${target} --${wasmProfile} → ${outDir}\n`, + ); + try { + await execFileAsync("wasm-pack", args(target, outDir), { cwd: repoRoot }); + } catch (err) { + if (err?.code === "ENOENT") { + console.error( + "wasm-pack is required. Install it with `cargo install wasm-pack` " + + "or see https://rustwasm.github.io/wasm-pack/installer/", + ); + process.exit(1); + } + throw err; + } + // wasm-pack writes a nested `.gitignore: *`; the repo-level ignore already + // owns generated WASM outputs. + await rm(resolve(outDir, ".gitignore"), { force: true }); +} + +await build("web", "web"); diff --git a/js/packages/truapi-host-wasm/src/adapter-support.ts b/js/packages/truapi-host-wasm/src/adapter-support.ts new file mode 100644 index 00000000..b2b999e7 --- /dev/null +++ b/js/packages/truapi-host-wasm/src/adapter-support.ts @@ -0,0 +1,125 @@ +// Hand-written runtime support for the generated `createWasmRawCallbacks` +// adapter (`./generated/host-callbacks-adapter.ts`). The adapter is mechanical +// (decode params, call the typed host callback, read the result); the pieces +// here are the genuinely bespoke runtime plumbing it leans on: stream driving +// and the chain-connection handle. + +import { type GenericError, type Result } from "@parity/truapi"; +import { hexToBytes } from "@parity/truapi/scale"; + +import type { ChainConnect, ChainConnection } from "./runtime.js"; +import type { ChainProvider } from "./generated/host-callbacks.js"; + +type WireResult = + | { success: true; value: T } + | { success: false; value: E }; + +type StreamResult = Result | WireResult; + +type MaybeAsyncIterable = AsyncIterable | Iterable; + +/** + * Normalize both generated `Result` values and the plain + * `{ success, value }` envelope used by some JS fixtures into a raw item. + */ +function unwrapStreamResult(item: StreamResult): T { + if ("success" in item) { + if (item.success === false) { + throw new Error(item.value.reason); + } + return item.value; + } + if (item.isErr()) { + throw new Error(item.error.reason); + } + return item.value; +} + +/** + * Accept sync and async host streams behind one async-iterator interface. + * Host callbacks often use async iterables in production, while tests can use + * small synchronous fixtures without a custom wrapper. + */ +function toAsyncIterator(stream: MaybeAsyncIterable): AsyncIterator { + const asyncIterable = stream as AsyncIterable; + if (typeof asyncIterable[Symbol.asyncIterator] === "function") { + return asyncIterable[Symbol.asyncIterator](); + } + const iterator = (stream as Iterable)[Symbol.iterator](); + const asyncIterator: AsyncIterator = { + next: async () => iterator.next(), + }; + if (iterator.return) { + asyncIterator.return = async () => iterator.return!(); + } + return asyncIterator; +} + +/** + * Drain an async iterator into a sink until disposed. This is used for + * callback streams where the Rust core owns cancellation but JS owns the + * iterator and any transport cleanup behind `return()`. + */ +function pumpIterator( + iterator: AsyncIterator, + onItem: (value: T) => void, + label: string, +): () => void { + let stopped = false; + void (async () => { + try { + while (!stopped) { + const next = await iterator.next(); + if (next.done) return; + onItem(next.value); + } + } catch (err) { + console.error(`[truapi host callbacks] ${label} failed:`, err); + } + })(); + return () => { + stopped = true; + void iterator.return?.(); + }; +} + +/** + * Drive a typed host stream of `Result` items into the core's `sendItem` + * sink, unwrapping each `Result` (or throwing on its error). Returns a + * disposer that stops iteration. + */ +export function driveResultStream( + stream: MaybeAsyncIterable>, + sendItem: (value: T) => void, +): () => void { + return pumpIterator( + toAsyncIterator(stream), + (value) => sendItem(unwrapStreamResult(value)), + "subscription", + ); +} + +/** + * Bridge the typed `ChainProvider.connect` callback onto the raw + * `chainConnect` the WASM core invokes: decode the genesis hash, pump the + * connection's `responses()` stream into `onResponse`, and expose + * `send`/`close`. + */ +export function chainConnectAdapter( + host: Pick, +): ChainConnect { + return async (genesisHash, onResponse): Promise => { + const connection = await host.connect(hexToBytes(genesisHash)); + const iterator = connection.responses()[Symbol.asyncIterator](); + const stopResponses = pumpIterator(iterator, onResponse, "chain responses"); + return { + send(request: string): void { + connection.send(request); + }, + close(): void { + stopResponses(); + connection.close(); + }, + }; + }; +} diff --git a/js/packages/truapi-host-wasm/src/error.ts b/js/packages/truapi-host-wasm/src/error.ts new file mode 100644 index 00000000..5dc5674e --- /dev/null +++ b/js/packages/truapi-host-wasm/src/error.ts @@ -0,0 +1,6 @@ +/** Coerce an unknown thrown value into a human-readable message string. */ +export function errorMessage(err: unknown): string { + if (err instanceof Error) return err.message; + if (typeof err === "string") return err; + return JSON.stringify(err) ?? String(err); +} diff --git a/js/packages/truapi-host-wasm/src/host-callbacks-adapter.test.ts b/js/packages/truapi-host-wasm/src/host-callbacks-adapter.test.ts new file mode 100644 index 00000000..f70a5f5d --- /dev/null +++ b/js/packages/truapi-host-wasm/src/host-callbacks-adapter.test.ts @@ -0,0 +1,359 @@ +import { describe, expect, it } from "bun:test"; +import { ok } from "neverthrow"; + +import { + HostDevicePermissionRequest, + HostDevicePermissionResponse, + HostFeatureSupportedRequest, + HostFeatureSupportedResponse, + HostPushNotificationRequest, + HostPushNotificationResponse, + RemotePermissionRequest, + RemotePermissionResponse, + ThemeVariant, +} from "@parity/truapi"; +import type { HostSignPayloadData } from "@parity/truapi"; + +import { createWasmRawCallbacks } from "./generated/host-callbacks-adapter.js"; +import { AuthState, CoreStorageKey, UserConfirmationReview } from "./generated/host-callbacks.js"; +import { makeHostCallbacks, settle } from "./test-support.js"; + +// The generated `createWasmRawCallbacks` adapter speaks the symmetric SCALE +// byte boundary: codec-typed requests arrive as `Uint8Array` and are decoded +// for the typed host callback; codec-typed responses are SCALE-encoded back to +// `Uint8Array`. Primitives, strings and byte blobs pass through unchanged. + +const GENESIS = `0x${"11".repeat(32)}` as `0x${string}`; +const PRODUCT_ACCOUNT = { + dotNsIdentifier: "playground.dot", + derivationIndex: 0, +}; +const SIGN_PAYLOAD: HostSignPayloadData = { + blockHash: GENESIS, + blockNumber: "0x01", + era: "0x00", + genesisHash: GENESIS, + method: "0x0102", + nonce: "0x00", + specVersion: "0x01", + tip: "0x00", + transactionVersion: "0x01", + signedExtensions: [], + version: 4, + assetId: undefined, + metadataHash: undefined, + mode: undefined, +}; + +describe("createWasmRawCallbacks", () => { + it("decodes requests and encodes typed responses", async () => { + const writes: [string, number[]][] = []; + const clears: string[] = []; + const cancelled: number[] = []; + const raw = createWasmRawCallbacks( + makeHostCallbacks({ + pushNotification: async (notification) => ({ + id: notification.text.length, + }), + cancelNotification: async (id) => { + cancelled.push(id); + }, + devicePermission: async (request) => ({ + granted: request === "Camera", + }), + remotePermission: async (request) => ({ + granted: request.permission.tag === "ChainSubmit", + }), + featureSupported: async (request) => ({ + supported: request.tag === "Chain" && request.value.genesisHash === GENESIS, + }), + read: async (key) => + new TextEncoder().encode(`read:${key}`), + write: async (key, value) => { + writes.push([key, [...value]]); + }, + clear: async (key) => { + clears.push(key); + }, + }), + ); + + expect( + HostPushNotificationResponse.dec( + await raw.pushNotification!( + HostPushNotificationRequest.enc({ + text: "hello", + deeplink: undefined, + scheduledAt: undefined, + }), + ), + ).id, + ).toBe(5); + expect( + HostDevicePermissionResponse.dec( + await raw.devicePermission!(HostDevicePermissionRequest.enc("Camera")), + ).granted, + ).toBe(true); + expect( + RemotePermissionResponse.dec( + await raw.remotePermission!( + RemotePermissionRequest.enc({ + permission: { tag: "ChainSubmit" }, + }), + ), + ).granted, + ).toBe(true); + expect( + HostFeatureSupportedResponse.dec( + await raw.featureSupported!( + HostFeatureSupportedRequest.enc({ + tag: "Chain", + value: { genesisHash: GENESIS }, + }), + ), + ).supported, + ).toBe(true); + expect(await raw.read!("session")).toEqual( + new TextEncoder().encode("read:session"), + ); + + await raw.write!("session", new Uint8Array([1, 2, 3])); + await raw.clear!("session"); + await raw.cancelNotification?.(9); + + expect(writes).toEqual([["session", [1, 2, 3]]]); + expect(clears).toEqual(["session"]); + expect(cancelled).toEqual([9]); + }); + + it("bridges lifecycle, confirmations, and preimage callbacks", async () => { + const calls: unknown[][] = []; + async function* preimages() { + yield ok(undefined); + yield ok(new Uint8Array([4, 5, 6])); + } + + const raw = createWasmRawCallbacks( + makeHostCallbacks({ + authStateChanged: (state) => { + calls.push(["authStateChanged", state]); + }, + readCoreStorage: async (key) => + key.tag === "AuthSession" ? new Uint8Array([1, 2, 3]) : undefined, + writeCoreStorage: async (key, value) => { + calls.push(["writeCoreStorage", key, [...value]]); + }, + clearCoreStorage: async (key) => { + calls.push(["clearCoreStorage", key]); + }, + confirmUserAction: async (review) => { + switch (review.tag) { + case "SignPayload": + return ( + review.value.tag === "Product" && + review.value.value.account.dotNsIdentifier === "playground.dot" && + review.value.value.payload.method === "0x0102" + ); + case "SignRaw": + return ( + review.value.tag === "Product" && + review.value.value.payload.tag === "Bytes" && + review.value.value.payload.value.bytes === "0x0304" + ); + case "CreateTransaction": + return ( + review.value.tag === "Product" && + review.value.value.signer.derivationIndex === 0 && + review.value.value.callData === "0x0506" + ); + case "AccountAlias": + return ( + review.value.requestingProductId === "playground.dot" && + review.value.targetProductId === "wallet.dot" + ); + case "ResourceAllocation": + return review.value.resources[0]?.tag === "StatementStoreAllowance"; + case "PreimageSubmit": + calls.push(["confirmUserAction:PreimageSubmit", review.value.size]); + return review.value.size === 42n; + } + }, + submitPreimage: async (value) => { + calls.push(["submitPreimage", [...value]]); + return new Uint8Array([7, 8, 9]); + }, + lookupPreimage: (key) => { + calls.push(["lookupPreimage", [...key]]); + return preimages(); + }, + }), + ); + + const preimageEvents: (number[] | null)[] = []; + const disposePreimages = raw.lookupPreimage!(new Uint8Array([9]), (value) => + preimageEvents.push(value ? [...value] : null), + ); + + raw.authStateChanged?.( + AuthState.enc({ + tag: "Pairing", + value: { deeplink: "polkadotapp://example" }, + }), + ); + const authSessionKey = CoreStorageKey.enc({ tag: "AuthSession" }); + expect(await raw.readCoreStorage!(authSessionKey)).toEqual(new Uint8Array([1, 2, 3])); + await raw.writeCoreStorage!(authSessionKey, new Uint8Array([3, 2, 1])); + await raw.clearCoreStorage!(authSessionKey); + expect( + await raw.confirmUserAction?.( + UserConfirmationReview.enc({ + tag: "SignPayload", + value: { + tag: "Product", + value: { + account: PRODUCT_ACCOUNT, + payload: SIGN_PAYLOAD, + }, + }, + }), + ), + ).toBe(true); + expect( + await raw.confirmUserAction?.( + UserConfirmationReview.enc({ + tag: "SignRaw", + value: { + tag: "Product", + value: { + account: PRODUCT_ACCOUNT, + payload: { + tag: "Bytes", + value: { bytes: "0x0304" }, + }, + }, + }, + }), + ), + ).toBe(true); + expect( + await raw.confirmUserAction?.( + UserConfirmationReview.enc({ + tag: "CreateTransaction", + value: { + tag: "Product", + value: { + signer: PRODUCT_ACCOUNT, + genesisHash: GENESIS, + callData: "0x0506", + extensions: [], + txExtVersion: 0, + }, + }, + }), + ), + ).toBe(true); + expect( + await raw.confirmUserAction?.( + UserConfirmationReview.enc({ + tag: "AccountAlias", + value: { + requestingProductId: "playground.dot", + targetProductId: "wallet.dot", + }, + }), + ), + ).toBe(true); + expect( + await raw.confirmUserAction?.( + UserConfirmationReview.enc({ + tag: "ResourceAllocation", + value: { + resources: [{ tag: "StatementStoreAllowance" }], + }, + }), + ), + ).toBe(true); + expect( + await raw.confirmUserAction?.( + UserConfirmationReview.enc({ + tag: "PreimageSubmit", + value: { size: 42n }, + }), + ), + ).toBe(true); + expect(await raw.submitPreimage!(new Uint8Array([6]))).toEqual(new Uint8Array([7, 8, 9])); + + await settle(); + await settle(); + + expect(preimageEvents).toEqual([null, [4, 5, 6]]); + expect(calls).toEqual([ + ["lookupPreimage", [9]], + ["authStateChanged", { tag: "Pairing", value: { deeplink: "polkadotapp://example" } }], + ["writeCoreStorage", { tag: "AuthSession", value: undefined }, [3, 2, 1]], + ["clearCoreStorage", { tag: "AuthSession", value: undefined }], + ["confirmUserAction:PreimageSubmit", 42n], + ["submitPreimage", [6]], + ]); + + disposePreimages?.(); + }); + + it("adapts typed result subscriptions", async () => { + async function* themes() { + yield ok("Dark"); + yield ok("Light"); + } + + const raw = createWasmRawCallbacks( + makeHostCallbacks({ + subscribeTheme: () => themes(), + }), + ); + const seen: ThemeVariant[] = []; + const dispose = raw.subscribeTheme?.((theme) => seen.push(ThemeVariant.dec(theme!))); + + await settle(); + await settle(); + + expect(seen).toEqual(["Dark", "Light"]); + dispose?.(); + }); + + it("bridges typed chain connections", async () => { + const sent: string[] = []; + const responses = ['{"jsonrpc":"2.0","id":1,"result":"ok"}']; + let closes = 0; + const raw = createWasmRawCallbacks( + makeHostCallbacks({ + connect: async (genesisHash) => { + expect([...genesisHash]).toEqual(Array(32).fill(0x11)); + return { + send(request) { + sent.push(request); + }, + async *responses() { + yield* responses; + }, + close() { + closes += 1; + }, + }; + }, + }), + ); + + expect(typeof raw.chainConnect).toBe("function"); + const received: string[] = []; + const connection = await raw.chainConnect!(GENESIS, (json) => received.push(json)); + expect(connection).toBeTruthy(); + + connection!.send('{"jsonrpc":"2.0","id":1,"method":"system_health"}'); + await settle(); + + expect(sent).toEqual(['{"jsonrpc":"2.0","id":1,"method":"system_health"}']); + expect(received).toEqual(responses); + connection!.close(); + expect(closes).toBe(1); + }); +}); diff --git a/js/packages/truapi-host-wasm/src/index.ts b/js/packages/truapi-host-wasm/src/index.ts new file mode 100644 index 00000000..b3744f17 --- /dev/null +++ b/js/packages/truapi-host-wasm/src/index.ts @@ -0,0 +1,3 @@ +export type { Payload, ProtocolMessage, WireProvider } from "@parity/truapi"; + +export * from "./runtime.js"; diff --git a/js/packages/truapi-host-wasm/src/runtime.ts b/js/packages/truapi-host-wasm/src/runtime.ts new file mode 100644 index 00000000..d91872eb --- /dev/null +++ b/js/packages/truapi-host-wasm/src/runtime.ts @@ -0,0 +1,83 @@ +import type { WireProvider } from "@parity/truapi"; +import { CoreStorageKey as GeneratedCoreStorageKey } from "./generated/host-callbacks.js"; +import type { + CoreAdmin, + CoreStorageKey, +} from "./generated/host-callbacks.js"; + +// The typed capability interfaces below come straight from the +// `truapi-platform` Rust crate via `truapi-codegen --platform-ts-output`. +// They are the host-author-facing surface: each method takes/returns +// typed wrappers (`HostDevicePermissionRequest`, etc.) rather than raw +// SCALE bytes. The web worker pairing-host runtime adapts this typed surface +// into the byte-oriented callback bridge consumed by the WASM core. +export * from "./generated/host-callbacks.js"; +export type { + JsonRpcConnection as PlatformJsonRpcConnection, +} from "./generated/host-callbacks.js"; + +/** Encode a typed core-storage slot for hosts that need an opaque backing key. */ +export function encodeCoreStorageKey(key: CoreStorageKey): Uint8Array { + return GeneratedCoreStorageKey.enc(key); +} + +/** + * Async-or-sync return. Synchronous hosts (e.g. the dotli main-thread + * shell hitting localStorage) can return a plain value; the WASM bridge + * awaits every return so an `async` impl also works. + */ +export type Awaitable = T | Promise; + +/** + * Open a JSON-RPC connection for `genesisHash`. The wasm bridge passes + * `onResponse` so the host can push JSON-RPC replies back asynchronously. + * Returning `null` (or throwing) tells the core no provider is available. + */ +export type ChainConnect = ( + genesisHash: string, + onResponse: (json: string) => void, +) => Awaitable; + +/** + * Per-connection handle returned by `chainConnect`. `send` forwards a + * SCALE-encoded JSON-RPC request; `close` tears the connection down. + */ +export interface ChainConnection { + send(request: string): void; + close(): void; +} + +/** + * Verbosity threshold for the wasm core's `tracing` output. The Rust core + * parses the string; known values are `off`, `error`, `warn`, `info`, `debug`, + * and `trace`. + */ +export type LogLevel = string; + +export interface ProductRuntimeConfig { + productId: string; + host: { + name: string; + icon?: string; + version?: string; + }; + platform?: { + type?: string; + version?: string; + }; + people: { + genesisHash: string | Uint8Array; + }; + pairing: { + deeplinkScheme: string; + }; +} + +export interface TrUApiProductProvider extends WireProvider, CoreAdmin { + /** + * Re-tune the wasm core's log level at runtime. Present on runtimes that + * keep a live channel to the core (e.g. the Web Worker provider); absent on + * one-shot constructions that only accept `logLevel` up front. + */ + setLogLevel?(level: LogLevel): void; +} diff --git a/js/packages/truapi-host-wasm/src/test-support.ts b/js/packages/truapi-host-wasm/src/test-support.ts new file mode 100644 index 00000000..346b180f --- /dev/null +++ b/js/packages/truapi-host-wasm/src/test-support.ts @@ -0,0 +1,75 @@ +import type { + FlatHostCallbacks, + RequiredHostCallbacks, +} from "./generated/host-callbacks.js"; + +/** `HostCallbacks` with every optional member required, for exhaustive test fixtures. */ +export type CompleteHostCallbacks = RequiredHostCallbacks; + +type FlatHostCallbackOverrides = Partial>; + +/** Default no-op host callbacks with optional per-test overrides. */ +export function makeHostCallbacks( + overrides: FlatHostCallbackOverrides = {}, +): CompleteHostCallbacks { + const flat: Required = { + navigateTo: async () => {}, + pushNotification: async () => ({ id: 0 }), + cancelNotification: async () => {}, + devicePermission: async () => ({ granted: false }), + remotePermission: async () => ({ granted: false }), + featureSupported: async () => ({ supported: false }), + readCoreStorage: async () => undefined, + writeCoreStorage: async () => {}, + clearCoreStorage: async () => {}, + read: async () => undefined, + write: async () => {}, + clear: async () => {}, + authStateChanged: () => {}, + confirmUserAction: async () => false, + submitPreimage: async () => new Uint8Array(), + async *lookupPreimage() {}, + async *subscribeTheme() {}, + connect: async () => ({ + send() {}, + async *responses() {}, + close() {}, + }), + ...overrides, + }; + return { + navigation: { navigateTo: flat.navigateTo }, + notifications: { + pushNotification: flat.pushNotification, + cancelNotification: flat.cancelNotification, + }, + permissions: { + devicePermission: flat.devicePermission, + remotePermission: flat.remotePermission, + }, + features: { featureSupported: flat.featureSupported }, + productStorage: { + read: flat.read, + write: flat.write, + clear: flat.clear, + }, + coreStorage: { + readCoreStorage: flat.readCoreStorage, + writeCoreStorage: flat.writeCoreStorage, + clearCoreStorage: flat.clearCoreStorage, + }, + auth: { authStateChanged: flat.authStateChanged }, + userConfirmation: { confirmUserAction: flat.confirmUserAction }, + preimage: { + submitPreimage: flat.submitPreimage, + lookupPreimage: flat.lookupPreimage, + }, + theme: { subscribeTheme: flat.subscribeTheme }, + chain: { connect: flat.connect }, + }; +} + +/** Resolve after the current microtask/immediate queue, letting pending async work run. */ +export function settle(): Promise { + return new Promise((resolve) => setImmediate(resolve)); +} diff --git a/js/packages/truapi-host-wasm/src/web/create-iframe-host.test.ts b/js/packages/truapi-host-wasm/src/web/create-iframe-host.test.ts new file mode 100644 index 00000000..9a9108bc --- /dev/null +++ b/js/packages/truapi-host-wasm/src/web/create-iframe-host.test.ts @@ -0,0 +1,215 @@ +import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"; + +import { createIframeHost } from "./index.js"; + +// Verify that `createIframeHost` hands a MessagePort back through `onPort`, +// constructs an iframe with the expected attributes, and posts the +// `truapi-init` handshake after the iframe reports readiness. + +function setupFakeDom() { + // Track listeners on the synthetic `window` and the iframe so the + // test can simulate the iframe `load` event after construction. + const iframeListeners = new Map void>(); + const windowListeners = new Map void>(); + const windowRemove = mock((_name: string, _fn: unknown) => {}); + const contentPostMessage = mock((_body: unknown, _origin: string) => {}); + + const contentWindow = { + postMessage: contentPostMessage, + }; + + const iframe = { + style: {} as Record, + setAttribute: mock((_name: string, _value: string) => {}), + addEventListener: (name: string, fn: (event: unknown) => void) => { + iframeListeners.set(name, fn); + }, + removeEventListener: () => {}, + remove: mock(() => {}), + referrerPolicy: "", + credentialless: false, + allow: "", + src: "", + contentWindow, + }; + + const container = { + appendChild: mock((_child: unknown) => {}), + }; + + // Spy on both MessageChannel ports so dispose() teardown is observable. + const port1 = { postMessage: mock(() => {}), close: mock(() => {}) }; + const port2 = { postMessage: mock(() => {}), close: mock(() => {}) }; + globalThis.MessageChannel = class { + port1 = port1; + port2 = port2; + } as unknown as typeof MessageChannel; + + globalThis.document = { + createElement: (tag: string) => { + expect(tag).toBe("iframe"); + return iframe as unknown as HTMLIFrameElement; + }, + } as unknown as Document; + globalThis.window = { + location: { href: "http://localhost:5174/" }, + addEventListener: (name: string, fn: (event: unknown) => void) => { + windowListeners.set(name, fn); + }, + removeEventListener: windowRemove, + } as unknown as Window & typeof globalThis; + + return { + iframe, + container, + contentPostMessage, + contentWindow, + iframeListeners, + windowListeners, + windowRemove, + port1, + port2, + }; +} + +function teardownFakeDom() { + delete (globalThis as { document?: unknown }).document; + delete (globalThis as { window?: unknown }).window; + delete (globalThis as { MessageChannel?: unknown }).MessageChannel; +} + +describe("createIframeHost", () => { + let dom: ReturnType; + + beforeEach(() => { + dom = setupFakeDom(); + }); + + afterEach(() => { + teardownFakeDom(); + }); + + it("hands back a MessagePort and configures the iframe", () => { + const { iframe, container, iframeListeners, windowRemove, port1, port2 } = dom; + + let receivedPort: MessagePort | null = null; + const host = createIframeHost({ + iframeUrl: "http://localhost:5174/", + container: container as unknown as HTMLElement, + allow: "camera; cross-origin-isolated", + onPort: (port) => { + receivedPort = port; + }, + }); + + expect(receivedPort).toBeTruthy(); + expect(typeof receivedPort!.postMessage).toBe("function"); + expect(container.appendChild.mock.calls.length).toBe(1); + expect(host.iframe).toBe(iframe as unknown as HTMLIFrameElement); + expect(iframe.credentialless).toBe(true); + expect(iframe.allow).toBe("camera; cross-origin-isolated"); + expect(iframe.src).toBe("http://localhost:5174/"); + // port transfer waits for explicit iframe readiness + expect(iframeListeners.has("load")).toBe(false); + + host.dispose(); + expect(iframe.remove.mock.calls.length).toBe(1); + // dispose removes the window message listener + expect(windowRemove.mock.calls.length).toBe(1); + expect(windowRemove.mock.calls[0][0]).toBe("message"); + // host + product ports closed on dispose + expect(port1.close.mock.calls.length).toBe(1); + expect(port2.close.mock.calls.length).toBe(1); + }); + + it("sends truapi-init on a same-origin product-ready message", () => { + const { contentPostMessage, windowListeners, contentWindow } = dom; + + createIframeHost({ + iframeUrl: "http://localhost:5174/", + container: { appendChild: () => {} } as unknown as HTMLElement, + onPort: () => {}, + }); + + const onMessage = windowListeners.get("message"); + expect(onMessage).toBeTruthy(); + + // Wrong source is dropped. + onMessage!({ + source: { other: true }, + origin: "http://localhost:5174", + data: { type: "truapi-ready" }, + }); + expect(contentPostMessage.mock.calls.length).toBe(0); + + // Wrong origin is dropped. + onMessage!({ + source: contentWindow, + origin: "http://evil.example", + data: { type: "truapi-ready" }, + }); + expect(contentPostMessage.mock.calls.length).toBe(0); + + // Correct source + origin triggers the init handshake. + onMessage!({ + source: contentWindow, + origin: "http://localhost:5174", + data: { type: "truapi-ready" }, + }); + expect(contentPostMessage.mock.calls.length).toBe(1); + const [body, origin] = contentPostMessage.mock.calls[0]; + expect(body).toEqual({ type: "truapi-init" }); + expect(origin).toBe("*"); + + // The handshake is idempotent across repeated ready events too. + onMessage!({ + source: contentWindow, + origin: "http://localhost:5174", + data: { type: "truapi-ready" }, + }); + expect(contentPostMessage.mock.calls.length).toBe(1); + }); + + it("accepts product-ready from a credentialless opaque origin", () => { + const { contentPostMessage, windowListeners, contentWindow } = dom; + + createIframeHost({ + iframeUrl: "http://localhost:5174/", + container: { appendChild: () => {} } as unknown as HTMLElement, + onPort: () => {}, + }); + + const onMessage = windowListeners.get("message"); + expect(onMessage).toBeTruthy(); + + onMessage!({ + source: contentWindow, + origin: "null", + data: { type: "truapi-ready" }, + }); + expect(contentPostMessage.mock.calls.length).toBe(1); + const [, origin] = contentPostMessage.mock.calls[0]; + expect(origin).toBe("*"); + }); + + it("rejects a mismatched allowedOrigin", () => { + expect(() => + createIframeHost({ + iframeUrl: "http://localhost:5174/", + container: { appendChild: () => {} } as unknown as HTMLElement, + onPort: () => {}, + allowedOrigin: "http://localhost:9999", + }), + ).toThrow(/origin policy mismatch/); + }); + + it("rejects non-http(s) iframe URLs", () => { + expect(() => + createIframeHost({ + iframeUrl: "file:///etc/passwd", + container: { appendChild: () => {} } as unknown as HTMLElement, + onPort: () => {}, + }), + ).toThrow(/only allows http\(s\)/); + }); +}); diff --git a/js/packages/truapi-host-wasm/src/web/create-iframe-host.ts b/js/packages/truapi-host-wasm/src/web/create-iframe-host.ts new file mode 100644 index 00000000..b5e3ce36 --- /dev/null +++ b/js/packages/truapi-host-wasm/src/web/create-iframe-host.ts @@ -0,0 +1,147 @@ +/** + * Options for `createIframeHost`. + */ +export interface IframeHostOptions { + /** URL of the product iframe. */ + iframeUrl: string; + /** Container element the iframe is appended to. */ + container: HTMLElement; + /** + * Called with one end of the MessageChannel once the iframe has loaded. + * Hosts typically pipe this into a `WireProvider` (e.g. via + * `createMessagePortProvider` from `@parity/truapi`). + */ + onPort: (port: MessagePort) => void; + /** + * Optional explicit allow-list origin. Defaults to the origin of + * `iframeUrl`. Throws if it disagrees with the iframe URL's origin. + */ + allowedOrigin?: string; + /** Optional iframe Permissions Policy allow attribute. */ + allow?: string; + /** Override the default iframe sandbox attribute. */ + sandbox?: string; +} + +/** + * Handle returned by `createIframeHost`. + */ +export interface IframeHost { + iframe: HTMLIFrameElement; + dispose: () => void; +} + +const DEFAULT_IFRAME_SANDBOX = "allow-forms allow-same-origin allow-scripts"; +type CredentiallessIframe = HTMLIFrameElement & { credentialless?: boolean }; + +function resolveAllowedOrigin( + iframeUrl: string, + allowedOrigin?: string, +): string { + const targetUrl = new URL(iframeUrl, window.location.href); + if (targetUrl.protocol !== "http:" && targetUrl.protocol !== "https:") { + throw new Error( + `Iframe host only allows http(s) product URLs, received ${targetUrl.protocol}`, + ); + } + + if (!allowedOrigin) { + return targetUrl.origin; + } + + const normalizedOrigin = new URL(allowedOrigin).origin; + if (normalizedOrigin !== targetUrl.origin) { + throw new Error( + `Iframe host origin policy mismatch, expected ${normalizedOrigin}, got ${targetUrl.origin}`, + ); + } + + return normalizedOrigin; +} + +/** + * Embed a product iframe and transfer a `MessagePort` into it. The host + * keeps the other end and passes it to a `WireProvider` (typically via + * `createMessagePortProvider`). All product traffic flows over the + * MessageChannel. + */ +export function createIframeHost(options: IframeHostOptions): IframeHost { + const { + iframeUrl, + container, + onPort, + allowedOrigin, + allow, + sandbox = DEFAULT_IFRAME_SANDBOX, + } = options; + + const channel = new MessageChannel(); + const hostPort = channel.port1; + const productPort = channel.port2; + const targetOrigin = resolveAllowedOrigin(iframeUrl, allowedOrigin); + + // Hand the host-side port to the caller immediately so it can wire up + // a provider before the iframe finishes loading. Queued postMessage + // calls are delivered once the channel is started by the provider. + onPort(hostPort); + + const iframe = document.createElement("iframe"); + iframe.style.width = "100%"; + iframe.style.height = "100%"; + iframe.style.border = "none"; + // COEP hosts need credentialless product iframes when the product origin + // does not serve matching embedder headers. + const credentiallessIframe = iframe as CredentiallessIframe; + credentiallessIframe.credentialless = true; + if (allow !== undefined) { + iframe.allow = allow; + } + iframe.setAttribute("sandbox", sandbox); + iframe.referrerPolicy = "no-referrer"; + iframe.src = iframeUrl; + const initTargetOrigin = credentiallessIframe.credentialless + ? "*" + : targetOrigin; + + let initSent = false; + const sendInit = (): void => { + if (initSent) return; + const contentWindow = iframe.contentWindow; + if (!contentWindow) return; + initSent = true; + contentWindow.postMessage({ type: "truapi-init" }, initTargetOrigin, [ + productPort, + ]); + }; + + const onWindowMessage = (event: MessageEvent): void => { + if (event.source !== iframe.contentWindow) return; + if (event.origin !== targetOrigin && event.origin !== "null") return; + if (event.data?.type === "truapi-ready") { + sendInit(); + } + }; + window.addEventListener("message", onWindowMessage); + + container.appendChild(iframe); + + return { + iframe, + dispose() { + window.removeEventListener("message", onWindowMessage); + try { + hostPort.close(); + } catch { + // already closed + } + try { + productPort.close(); + } catch { + // already closed + } + iframe.remove(); + }, + }; +} + +export type { WireProvider } from "@parity/truapi"; diff --git a/js/packages/truapi-host-wasm/src/web/create-worker-host-runtime.ts b/js/packages/truapi-host-wasm/src/web/create-worker-host-runtime.ts new file mode 100644 index 00000000..3116816d --- /dev/null +++ b/js/packages/truapi-host-wasm/src/web/create-worker-host-runtime.ts @@ -0,0 +1,896 @@ +import type { + ChainConnection, + ProductRuntimeConfig, + LogLevel, + PermissionAuthorizationRequest, + PermissionAuthorizationStatus, + RequiredHostCallbacks, + TrUApiProductProvider, +} from "../index.js"; +import { PermissionAuthorizationRequest as PermissionAuthorizationRequestCodec } from "../generated/host-callbacks.js"; +import { createWasmRawCallbacks } from "../generated/host-callbacks-adapter.js"; +import type { RawCallbacks } from "../generated/host-callbacks-adapter.js"; +import type { + CallbackName, + MainToWorker, + SubscriptionName, + WorkerToMain, +} from "../worker-protocol.js"; +import { bytesToHex } from "@parity/truapi/scale"; +import { startRawSubscription } from "../generated/worker-callbacks.js"; +import { errorMessage } from "../error.js"; + +export type WebWorkerHostConfig = Omit; + +export interface WorkerPairingHostRuntime { + createProvider(product: { + productId: string; + }): Promise; + disconnectSession(): Promise; + cancelPairing(): void; + notifySessionStoreChanged(): void; + getPermissionAuthorizationStatus( + productId: string, + request: PermissionAuthorizationRequest, + ): Promise; + getPermissionAuthorizationStatuses( + productId: string, + requests: PermissionAuthorizationRequest[], + ): Promise; + setPermissionAuthorizationStatus( + productId: string, + request: PermissionAuthorizationRequest, + status: PermissionAuthorizationStatus, + ): Promise; + setLogLevel(level: LogLevel): void; + dispose(): void; +} + +interface CoreState { + coreId: number; + productId: string; + listeners: Set<(message: Uint8Array) => void>; + closeListeners: Set<(error: Error) => void>; + closedError: Error | null; + disposed: boolean; +} + +interface RuntimeState { + worker: Worker; + rawCallbacks: RawCallbacks; + cores: Map; + pendingCores: Map< + number, + { + productId: string; + resolve: (provider: TrUApiProductProvider) => void; + reject: (error: Error) => void; + } + >; + subscriptionDisposers: Map void>; + chainConnections: Map; + pendingDisconnects: Map< + number, + { resolve: () => void; reject: (error: Error) => void } + >; + pendingPermissionAuthorizationStatuses: Map< + number, + { + resolve: (status: PermissionAuthorizationStatus) => void; + reject: (error: Error) => void; + } + >; + pendingPermissionAuthorizationStatusBatches: Map< + number, + { + resolve: (statuses: PermissionAuthorizationStatus[]) => void; + reject: (error: Error) => void; + } + >; + pendingSetPermissionAuthorizationStatuses: Map< + number, + { resolve: () => void; reject: (error: Error) => void } + >; + closedError: Error | null; + logLevel: LogLevel; + disposed: boolean; + nextCoreId: number; +} + +function debugLoggingEnabled(state: RuntimeState): boolean { + return state.logLevel === "debug" || state.logLevel === "trace"; +} + +let nextDisconnectRequestId = 0; +let nextPermissionAuthorizationRequestId = 0; + +function encodePermissionAuthorizationRequest( + request: PermissionAuthorizationRequest, +): Uint8Array { + return PermissionAuthorizationRequestCodec.enc(request); +} + +const DEV_LOG_LEVEL_KEY = "truapi:logLevel"; + +function readPersistedLogLevel(): LogLevel | null { + return globalThis.localStorage?.getItem(DEV_LOG_LEVEL_KEY) ?? null; +} + +function persistLogLevel(level: LogLevel): void { + globalThis.localStorage?.setItem(DEV_LOG_LEVEL_KEY, level); +} + +let devLogLevelOverride: LogLevel | null = readPersistedLogLevel(); +const devGlobalTargets = new Set<{ setLogLevel?: (level: LogLevel) => void }>(); +interface TrUApiDevConsole { + setLogLevel(level: LogLevel): void; + getLogLevel(): LogLevel | null; +} + +function handleCallbackRequest( + state: RuntimeState, + msg: { + requestId: number; + name: CallbackName; + args: readonly unknown[]; + }, +): void { + const fn = Object.hasOwn(state.rawCallbacks, msg.name) + ? ( + state.rawCallbacks as unknown as Record< + string, + (...args: readonly unknown[]) => unknown + > + )[msg.name] + : undefined; + if (!fn) { + state.worker.postMessage({ + kind: "callbackResponse", + requestId: msg.requestId, + ok: false, + error: `unknown callback: ${msg.name}`, + } satisfies MainToWorker); + return; + } + Promise.resolve() + .then(() => fn(...msg.args)) + .then( + (value) => { + state.worker.postMessage({ + kind: "callbackResponse", + requestId: msg.requestId, + ok: true, + value, + } satisfies MainToWorker); + }, + (err) => { + state.worker.postMessage({ + kind: "callbackResponse", + requestId: msg.requestId, + ok: false, + error: errorMessage(err), + } satisfies MainToWorker); + }, + ); +} + +function handleSubscriptionStart( + state: RuntimeState, + msg: { + subId: number; + name: SubscriptionName; + payload: Uint8Array | null; + }, +): void { + const sendItem = (value?: unknown): void => { + if (state.disposed) return; + state.worker.postMessage({ + kind: "subscriptionItem", + subId: msg.subId, + value, + } satisfies MainToWorker); + }; + let dispose: (() => void) | void = undefined; + try { + dispose = startRawSubscription( + state.rawCallbacks, + msg.name, + msg.payload, + sendItem, + ); + } catch (err) { + console.error(`[truapi worker] ${msg.name} threw on start:`, err); + return; + } + if (typeof dispose === "function") { + state.subscriptionDisposers.set(msg.subId, dispose); + } +} + +function handleSubscriptionStop( + state: RuntimeState, + msg: { subId: number }, +): void { + const dispose = state.subscriptionDisposers.get(msg.subId); + if (!dispose) return; + state.subscriptionDisposers.delete(msg.subId); + try { + dispose(); + } catch (err) { + console.warn("[truapi worker] subscription dispose threw:", err); + } +} + +async function handleChainConnectStart( + state: RuntimeState, + msg: { connId: number; genesisHash: string }, +): Promise { + const chainConnect = state.rawCallbacks.chainConnect; + const onResponse = (json: string): void => { + if (state.disposed) return; + state.worker.postMessage({ + kind: "chainResponse", + connId: msg.connId, + json, + } satisfies MainToWorker); + }; + try { + const conn = await chainConnect(msg.genesisHash, onResponse); + if (!conn) { + state.worker.postMessage({ + kind: "chainConnectAck", + connId: msg.connId, + ok: false, + error: `chainConnect returned null for genesisHash ${msg.genesisHash}`, + } satisfies MainToWorker); + return; + } + state.chainConnections.set(msg.connId, conn); + state.worker.postMessage({ + kind: "chainConnectAck", + connId: msg.connId, + ok: true, + } satisfies MainToWorker); + } catch (err) { + state.worker.postMessage({ + kind: "chainConnectAck", + connId: msg.connId, + ok: false, + error: errorMessage(err), + } satisfies MainToWorker); + } +} + +function handleChainSend( + state: RuntimeState, + msg: { connId: number; request: string }, +): void { + const conn = state.chainConnections.get(msg.connId); + if (!conn) return; + try { + if (debugLoggingEnabled(state)) { + console.debug("[truapi worker] chainSend", msg.connId, msg.request); + } + conn.send(msg.request); + } catch (err) { + console.warn("[truapi worker] chain send threw:", err); + } +} + +function handleChainClose(state: RuntimeState, msg: { connId: number }): void { + const conn = state.chainConnections.get(msg.connId); + if (!conn) return; + state.chainConnections.delete(msg.connId); + try { + conn.close(); + } catch (err) { + console.warn("[truapi worker] chain close threw:", err); + } +} + +interface PendingEntry { + resolve: (value: T) => void; + reject: (error: Error) => void; +} + +function settlePending( + map: Map>, + requestId: number, + result: { ok: true; value: T } | { ok: false; error: string }, +): void { + const pending = map.get(requestId); + if (!pending) return; + map.delete(requestId); + if (result.ok) pending.resolve(result.value); + else pending.reject(new Error(result.error)); +} + +function rejectAll(map: Map>, error: Error): void { + for (const pending of map.values()) { + pending.reject(error); + } + map.clear(); +} + +function handleDisconnectResponse( + state: RuntimeState, + msg: + | { requestId: number; ok: true } + | { requestId: number; ok: false; error: string }, +): void { + settlePending( + state.pendingDisconnects, + msg.requestId, + msg.ok ? { ok: true, value: undefined } : { ok: false, error: msg.error }, + ); +} + +function handlePermissionAuthorizationStatusResponse( + state: RuntimeState, + msg: + | { + requestId: number; + ok: true; + status: PermissionAuthorizationStatus; + } + | { requestId: number; ok: false; error: string }, +): void { + settlePending( + state.pendingPermissionAuthorizationStatuses, + msg.requestId, + msg.ok ? { ok: true, value: msg.status } : { ok: false, error: msg.error }, + ); +} + +function handlePermissionAuthorizationStatusesResponse( + state: RuntimeState, + msg: + | { + requestId: number; + ok: true; + statuses: PermissionAuthorizationStatus[]; + } + | { requestId: number; ok: false; error: string }, +): void { + settlePending( + state.pendingPermissionAuthorizationStatusBatches, + msg.requestId, + msg.ok + ? { ok: true, value: msg.statuses } + : { ok: false, error: msg.error }, + ); +} + +function handleSetPermissionAuthorizationStatusResponse( + state: RuntimeState, + msg: + | { requestId: number; ok: true } + | { requestId: number; ok: false; error: string }, +): void { + settlePending( + state.pendingSetPermissionAuthorizationStatuses, + msg.requestId, + msg.ok ? { ok: true, value: undefined } : { ok: false, error: msg.error }, + ); +} + +function rejectPendingRuntimeRequests(state: RuntimeState, error: Error): void { + rejectAll(state.pendingDisconnects, error); + rejectAll(state.pendingPermissionAuthorizationStatuses, error); + rejectAll(state.pendingPermissionAuthorizationStatusBatches, error); + rejectAll(state.pendingSetPermissionAuthorizationStatuses, error); + for (const pending of state.pendingCores.values()) { + pending.reject(error); + } + state.pendingCores.clear(); +} + +function sendWorkerRequest( + state: RuntimeState, + pending: Map>, + nextId: () => number, + disposedFallback: T, + buildMessage: (requestId: number) => MainToWorker, +): Promise { + if (state.disposed) return Promise.resolve(disposedFallback); + return new Promise((resolve, reject) => { + const requestId = nextId(); + pending.set(requestId, { resolve, reject }); + try { + state.worker.postMessage(buildMessage(requestId)); + } catch (err) { + pending.delete(requestId); + reject(err instanceof Error ? err : new Error(String(err))); + } + }); +} + +function closeCoreState(core: CoreState, error: Error): void { + if (core.disposed) return; + core.disposed = true; + core.closedError = error; + for (const listener of [...core.closeListeners]) listener(error); + core.listeners.clear(); + core.closeListeners.clear(); +} + +function teardown(state: RuntimeState, error: Error, fault: boolean): void { + if (state.disposed) return; + state.disposed = true; + state.closedError = error; + rejectPendingRuntimeRequests(state, error); + for (const core of state.cores.values()) { + closeCoreState(core, error); + } + state.cores.clear(); + for (const fn of state.subscriptionDisposers.values()) { + try { + fn(); + } catch { + // ignore during teardown + } + } + state.subscriptionDisposers.clear(); + for (const conn of state.chainConnections.values()) { + try { + conn.close(); + } catch { + // ignore during teardown + } + } + state.chainConnections.clear(); + if (fault) { + state.worker.terminate(); + } else { + try { + state.worker.postMessage({ kind: "dispose" } satisfies MainToWorker); + } catch { + // ignore if worker already gone + } + setTimeout(() => state.worker.terminate(), 0); + } +} + +export interface CreateWebWorkerPairingHostRuntimeOptions { + logLevel?: LogLevel; + hostConfig: WebWorkerHostConfig; + initTimeoutMs?: number; +} + +export type WebWorkerHostCallbacks = RequiredHostCallbacks; + +export function createWebWorkerPairingHostRuntime( + worker: Worker, + host: WebWorkerHostCallbacks, + options: CreateWebWorkerPairingHostRuntimeOptions, +): Promise { + const callbacks = createWasmRawCallbacks(host); + + return new Promise((resolve, reject) => { + const state: RuntimeState = { + worker, + rawCallbacks: callbacks, + cores: new Map(), + pendingCores: new Map(), + subscriptionDisposers: new Map(), + chainConnections: new Map(), + pendingDisconnects: new Map(), + pendingPermissionAuthorizationStatuses: new Map(), + pendingPermissionAuthorizationStatusBatches: new Map(), + pendingSetPermissionAuthorizationStatuses: new Map(), + closedError: null, + logLevel: devLogLevelOverride ?? options.logLevel ?? "off", + disposed: false, + nextCoreId: 0, + }; + + let runtime: WorkerPairingHostRuntime | null = null; + + const notifyFault = (error: Error): void => { + teardown(state, error, true); + }; + + const onMessage = (ev: MessageEvent): void => { + const msg = ev.data; + switch (msg.kind) { + case "loaded": + case "ready": + break; + case "coreReady": + handleCoreReady(state, msg.coreId, runtime); + break; + case "coreError": + handleCoreError(state, msg.coreId, msg.error); + break; + case "fatalError": + console.error("[truapi worker]", msg.error); + notifyFault(new Error(`worker fatal error: ${msg.error}`)); + break; + case "frameError": + handleFrameError(state, msg.coreId, msg.error); + break; + case "disposeError": + console.warn("[truapi worker] dispose:", msg.error); + break; + case "frame": { + const core = state.cores.get(msg.coreId); + if (!core || core.disposed) break; + if (debugLoggingEnabled(state)) { + console.debug("[truapi worker] frame <-", bytesToHex(msg.bytes)); + } + for (const listener of [...core.listeners]) listener(msg.bytes); + break; + } + case "disconnectSessionResponse": + handleDisconnectResponse(state, msg); + break; + case "permissionAuthorizationStatusResponse": + handlePermissionAuthorizationStatusResponse(state, msg); + break; + case "permissionAuthorizationStatusesResponse": + handlePermissionAuthorizationStatusesResponse(state, msg); + break; + case "setPermissionAuthorizationStatusResponse": + handleSetPermissionAuthorizationStatusResponse(state, msg); + break; + case "callbackRequest": + if (debugLoggingEnabled(state)) { + console.debug("[truapi worker] callbackRequest", msg.name); + } + handleCallbackRequest(state, msg); + break; + case "subscriptionStart": + handleSubscriptionStart(state, msg); + break; + case "subscriptionStop": + handleSubscriptionStop(state, msg); + break; + case "chainConnectStart": + if (debugLoggingEnabled(state)) { + console.debug("[truapi worker] chainConnectStart", msg.connId); + } + void handleChainConnectStart(state, msg); + break; + case "chainSend": + handleChainSend(state, msg); + break; + case "chainClose": + handleChainClose(state, msg); + break; + default: { + const { kind } = msg as { kind?: unknown }; + console.warn( + `[truapi worker] unknown worker message kind: ${String(kind)}`, + ); + } + } + }; + + const onError = (e: ErrorEvent): void => { + cleanupInit(); + worker.terminate(); + reject(new Error(`worker init failed: ${e.message}`)); + }; + + const onInitMessageError = (): void => { + cleanupInit(); + worker.terminate(); + reject(new Error("worker message could not be deserialized during init")); + }; + + const onRuntimeError = (e: ErrorEvent): void => { + console.error("[truapi worker]", e.message); + notifyFault(new Error(`worker error: ${e.message}`)); + }; + + const onMessageError = (): void => { + notifyFault(new Error("worker message could not be deserialized")); + }; + + const onInitMessage = (ev: MessageEvent): void => { + const msg = ev.data; + if (msg.kind === "loaded") { + worker.postMessage({ + kind: "init", + logLevel: devLogLevelOverride ?? options.logLevel ?? "off", + hostConfig: options.hostConfig, + } satisfies MainToWorker); + } else if (msg.kind === "ready") { + cleanupInit(); + worker.addEventListener("message", onMessage); + worker.addEventListener("error", onRuntimeError); + worker.addEventListener("messageerror", onMessageError); + runtime = buildRuntime(state); + exposeDevGlobal(runtime); + resolve(runtime); + } else if (msg.kind === "fatalError") { + cleanupInit(); + worker.terminate(); + reject(new Error(`worker init reported error: ${msg.error}`)); + } + }; + + const cleanupInit = (): void => { + clearTimeout(initTimeout); + worker.removeEventListener("error", onError); + worker.removeEventListener("messageerror", onInitMessageError); + worker.removeEventListener("message", onInitMessage); + }; + + const timeoutMs = options.initTimeoutMs ?? 30_000; + const initTimeout = setTimeout(() => { + cleanupInit(); + worker.terminate(); + reject(new Error(`worker init timed out after ${timeoutMs}ms`)); + }, timeoutMs); + + worker.addEventListener("error", onError); + worker.addEventListener("messageerror", onInitMessageError); + worker.addEventListener("message", onInitMessage); + }); +} + +function handleCoreReady( + state: RuntimeState, + coreId: number, + runtime: WorkerPairingHostRuntime | null, +): void { + const pending = state.pendingCores.get(coreId); + if (!pending || !runtime) return; + state.pendingCores.delete(coreId); + const core: CoreState = { + coreId, + productId: pending.productId, + listeners: new Set(), + closeListeners: new Set(), + closedError: null, + disposed: false, + }; + state.cores.set(coreId, core); + pending.resolve(buildProvider(state, core, runtime)); +} + +function handleCoreError( + state: RuntimeState, + coreId: number, + error: string, +): void { + const pending = state.pendingCores.get(coreId); + if (!pending) return; + state.pendingCores.delete(coreId); + pending.reject(new Error(error)); +} + +function handleFrameError( + state: RuntimeState, + coreId: number, + error: string, +): void { + console.error("[truapi worker]", error); + const core = state.cores.get(coreId); + if (!core) return; + closeCoreState(core, new Error(`worker frame error: ${error}`)); + state.cores.delete(coreId); + try { + state.worker.postMessage({ + kind: "disposeCore", + coreId, + } satisfies MainToWorker); + } catch { + // ignore if worker is already gone + } +} + +function buildRuntime(state: RuntimeState): WorkerPairingHostRuntime { + const runtime: WorkerPairingHostRuntime = { + createProvider(product): Promise { + if (state.disposed) { + return Promise.reject( + state.closedError ?? new Error("runtime disposed"), + ); + } + return new Promise((resolve, reject) => { + const coreId = ++state.nextCoreId; + state.pendingCores.set(coreId, { + productId: product.productId, + resolve, + reject, + }); + try { + state.worker.postMessage({ + kind: "createCore", + coreId, + product, + } satisfies MainToWorker); + } catch (err) { + state.pendingCores.delete(coreId); + reject(err instanceof Error ? err : new Error(String(err))); + } + }); + }, + disconnectSession(): Promise { + return sendWorkerRequest( + state, + state.pendingDisconnects, + () => ++nextDisconnectRequestId, + undefined, + (requestId) => ({ kind: "disconnectSession", requestId }), + ); + }, + cancelPairing(): void { + if (state.disposed) return; + state.worker.postMessage({ + kind: "cancelPairing", + } satisfies MainToWorker); + }, + notifySessionStoreChanged(): void { + if (state.disposed) return; + state.worker.postMessage({ + kind: "notifySessionStoreChanged", + } satisfies MainToWorker); + }, + getPermissionAuthorizationStatus(productId, request) { + return sendWorkerRequest( + state, + state.pendingPermissionAuthorizationStatuses, + () => ++nextPermissionAuthorizationRequestId, + "NotDetermined", + (requestId) => ({ + kind: "getPermissionAuthorizationStatus", + productId, + requestId, + request: encodePermissionAuthorizationRequest(request), + }), + ); + }, + getPermissionAuthorizationStatuses(productId, requests) { + return sendWorkerRequest( + state, + state.pendingPermissionAuthorizationStatusBatches, + () => ++nextPermissionAuthorizationRequestId, + requests.map(() => "NotDetermined"), + (requestId) => ({ + kind: "getPermissionAuthorizationStatuses", + productId, + requestId, + requests: requests.map(encodePermissionAuthorizationRequest), + }), + ); + }, + setPermissionAuthorizationStatus(productId, request, status) { + return sendWorkerRequest( + state, + state.pendingSetPermissionAuthorizationStatuses, + () => ++nextPermissionAuthorizationRequestId, + undefined, + (requestId) => ({ + kind: "setPermissionAuthorizationStatus", + productId, + requestId, + request: encodePermissionAuthorizationRequest(request), + status, + }), + ); + }, + setLogLevel(level): void { + if (state.disposed) return; + state.logLevel = level; + state.worker.postMessage({ + kind: "setLogLevel", + level, + } satisfies MainToWorker); + }, + dispose(): void { + devGlobalTargets.delete(runtime); + teardown(state, new Error("runtime disposed"), false); + }, + }; + return runtime; +} + +function buildProvider( + state: RuntimeState, + core: CoreState, + runtime: WorkerPairingHostRuntime, +): TrUApiProductProvider { + const provider: TrUApiProductProvider = { + postMessage(bytes: Uint8Array): void { + if (state.disposed || core.disposed) return; + if (debugLoggingEnabled(state)) { + console.debug("[truapi worker] frame ->", bytesToHex(bytes)); + } + state.worker.postMessage({ + kind: "frame", + coreId: core.coreId, + bytes, + } satisfies MainToWorker); + }, + subscribe(callback) { + core.listeners.add(callback); + return () => { + core.listeners.delete(callback); + }; + }, + subscribeClose(callback) { + const closed = core.closedError ?? state.closedError; + if (closed) { + callback(closed); + return () => {}; + } + core.closeListeners.add(callback); + return () => { + core.closeListeners.delete(callback); + }; + }, + disconnectSession(): Promise { + if (core.disposed) return Promise.resolve(); + return runtime.disconnectSession(); + }, + getPermissionAuthorizationStatus(request) { + if (core.disposed) return Promise.resolve("NotDetermined"); + return runtime.getPermissionAuthorizationStatus(core.productId, request); + }, + getPermissionAuthorizationStatuses(requests) { + if (core.disposed) { + return Promise.resolve(requests.map(() => "NotDetermined")); + } + return runtime.getPermissionAuthorizationStatuses( + core.productId, + requests, + ); + }, + setPermissionAuthorizationStatus(request, status) { + if (core.disposed) return Promise.resolve(); + return runtime.setPermissionAuthorizationStatus( + core.productId, + request, + status, + ); + }, + setLogLevel(level): void { + if (core.disposed) return; + runtime.setLogLevel(level); + }, + dispose(): void { + if (core.disposed) return; + closeCoreState(core, new Error("provider disposed")); + state.cores.delete(core.coreId); + state.worker.postMessage({ + kind: "disposeCore", + coreId: core.coreId, + } satisfies MainToWorker); + }, + }; + return provider; +} + +function exposeDevGlobal(target: { + setLogLevel?: (level: LogLevel) => void; +}): void { + devGlobalTargets.add(target); + if (devLogLevelOverride !== null) { + target.setLogLevel?.(devLogLevelOverride); + } + publishDevGlobal(); +} + +function publishDevGlobal(): void { + const target = globalThis as { + __truapi?: TrUApiDevConsole; + }; + target.__truapi = { + setLogLevel(level: LogLevel): void { + devLogLevelOverride = level; + persistLogLevel(level); + for (const provider of [...devGlobalTargets]) { + provider.setLogLevel?.(level); + } + console.info(`[truapi worker] logLevel=${level}`); + }, + getLogLevel(): LogLevel | null { + return devLogLevelOverride; + }, + }; +} + +publishDevGlobal(); diff --git a/js/packages/truapi-host-wasm/src/web/index.ts b/js/packages/truapi-host-wasm/src/web/index.ts new file mode 100644 index 00000000..acba3a71 --- /dev/null +++ b/js/packages/truapi-host-wasm/src/web/index.ts @@ -0,0 +1,9 @@ +export type { IframeHost, IframeHostOptions } from "./create-iframe-host.js"; +export { createIframeHost } from "./create-iframe-host.js"; +export type { + CreateWebWorkerPairingHostRuntimeOptions, + WebWorkerHostConfig, + WebWorkerHostCallbacks, + WorkerPairingHostRuntime, +} from "./create-worker-host-runtime.js"; +export { createWebWorkerPairingHostRuntime } from "./create-worker-host-runtime.js"; diff --git a/js/packages/truapi-host-wasm/src/web/worker-provider.test.ts b/js/packages/truapi-host-wasm/src/web/worker-provider.test.ts new file mode 100644 index 00000000..e0e6a3f8 --- /dev/null +++ b/js/packages/truapi-host-wasm/src/web/worker-provider.test.ts @@ -0,0 +1,786 @@ +import { describe, expect, it } from "bun:test"; +import { ok } from "neverthrow"; + +import { HostPushNotificationRequest, HostPushNotificationResponse } from "@parity/truapi"; +import type { GenericError, Result, ThemeVariant } from "@parity/truapi"; + +import { createWasmRawCallbacks } from "../generated/host-callbacks-adapter.js"; +import { AuthState, CoreStorageKey } from "../generated/host-callbacks.js"; +import type { AuthState as AuthStateValue, PreimageHost } from "../generated/host-callbacks.js"; +import type { ProductRuntimeConfig, TrUApiProductProvider } from "../runtime.js"; +import { makeHostCallbacks, settle } from "../test-support.js"; +import { createWebWorkerPairingHostRuntime } from "./index.js"; +import type { CreateWebWorkerPairingHostRuntimeOptions } from "./index.js"; + +type WorkerMessage = Record; + +/** Minimal `Worker` stand-in that records posted messages and lets a test + * drive the `message`/`error`/`messageerror` events by hand. */ +class FakeWorker { + listeners = new Map void>>(); + messages: WorkerMessage[] = []; + terminated = false; + + addEventListener(name: string, fn: (event: unknown) => void) { + const listeners = this.listeners.get(name) ?? new Set(); + listeners.add(fn); + this.listeners.set(name, listeners); + } + + removeEventListener(name: string, fn: (event: unknown) => void) { + this.listeners.get(name)?.delete(fn); + } + + postMessage(message: WorkerMessage) { + this.messages.push(message); + } + + terminate() { + this.terminated = true; + } + + emit(message: WorkerMessage) { + for (const listener of this.listeners.get("message") ?? []) { + listener({ data: message }); + } + } + + emitError(message: string) { + for (const listener of this.listeners.get("error") ?? []) { + listener({ message }); + } + } + + emitMessageError() { + for (const listener of this.listeners.get("messageerror") ?? []) { + listener({ data: null }); + } + } +} + +/** Coerce the `FakeWorker` to the `Worker` shape the provider expects. */ +function asWorker(worker: FakeWorker): Worker { + return worker as unknown as Worker; +} + +function runtimeConfig(overrides: Partial = {}): ProductRuntimeConfig { + return { + productId: "dotli.dot", + host: { + name: "Polkadot Web", + icon: "https://dot.li/dotli.png", + version: "0.5.0", + }, + platform: { + type: "node", + version: process.versions.node, + }, + people: { + genesisHash: "0xa22a2424d2cbf561eaecf7da8b1b548fa9d1939f60265e942b1049616a012f71", + }, + pairing: { + deeplinkScheme: "polkadotapp", + }, + ...overrides, + }; +} + +function hostConfigFromRuntimeConfig( + config: ProductRuntimeConfig, +): CreateWebWorkerPairingHostRuntimeOptions["hostConfig"] { + const { productId: _productId, ...hostConfig } = config; + return hostConfig; +} + +function lastMessageOfKind(worker: FakeWorker, kind: string): WorkerMessage { + const message = [...worker.messages].reverse().find((m) => m.kind === kind); + expect(message).toBeDefined(); + return message!; +} + +async function finishProviderReady( + worker: FakeWorker, + providerPromise: Promise, +) { + await settle(); + const createCore = lastMessageOfKind(worker, "createCore"); + worker.emit({ kind: "coreReady", coreId: createCore.coreId }); + return providerPromise; +} + +type ReadyOptions = Partial> & { + createWebWorkerPairingHostRuntime?: typeof createWebWorkerPairingHostRuntime; + runtimeConfig?: ProductRuntimeConfig; +}; + +async function createProviderFromRuntime( + worker: Worker, + host: ReturnType, + options: ReadyOptions, +): Promise { + const { + createWebWorkerPairingHostRuntime: createRuntime = createWebWorkerPairingHostRuntime, + runtimeConfig: cfg = runtimeConfig(), + ...runtimeOptions + } = options; + const runtime = await createRuntime(worker, host, { + ...runtimeOptions, + hostConfig: hostConfigFromRuntimeConfig(cfg), + }); + const provider = await runtime.createProvider({ productId: cfg.productId }); + return { + ...provider, + dispose(): void { + provider.dispose(); + runtime.dispose(); + }, + }; +} + +async function readyProvider(worker: FakeWorker, options: ReadyOptions = {}) { + const providerPromise = createProviderFromRuntime(asWorker(worker), makeHostCallbacks(), options); + worker.emit({ kind: "loaded" }); + worker.emit({ kind: "ready" }); + return finishProviderReady(worker, providerPromise); +} + +/** Typed view of the dev console the worker runtime publishes on `globalThis`. */ +type TruapiDevConsole = { + setLogLevel(level: string): void; + getLogLevel(): string | null; +}; +const devGlobal = globalThis as typeof globalThis & { + __truapi?: TruapiDevConsole; +}; + +describe("createWebWorkerPairingHostRuntime", () => { + it("initializes the worker without a callback manifest", async () => { + const worker = new FakeWorker(); + const config = runtimeConfig(); + const providerPromise = createProviderFromRuntime(asWorker(worker), makeHostCallbacks(), { + logLevel: "debug", + runtimeConfig: config, + }); + + worker.emit({ kind: "loaded" }); + expect(worker.messages.length).toBe(1); + expect(worker.messages[0]).toEqual({ + kind: "init", + logLevel: "debug", + hostConfig: hostConfigFromRuntimeConfig(config), + }); + + worker.emit({ kind: "ready" }); + await settle(); + const createCore = lastMessageOfKind(worker, "createCore"); + expect(createCore).toEqual({ + kind: "createCore", + coreId: 1, + product: { productId: "dotli.dot" }, + }); + worker.emit({ kind: "coreReady", coreId: 1 }); + const provider = await providerPromise; + expect(typeof provider.disconnectSession).toBe("function"); + + provider.dispose(); + }); + + it("creates multiple product cores on one worker runtime", async () => { + const worker = new FakeWorker(); + const config = runtimeConfig(); + const runtimePromise = createWebWorkerPairingHostRuntime(asWorker(worker), makeHostCallbacks(), { + hostConfig: hostConfigFromRuntimeConfig(config), + }); + worker.emit({ kind: "loaded" }); + worker.emit({ kind: "ready" }); + const runtime = await runtimePromise; + + const firstPromise = runtime.createProvider({ productId: "first.dot" }); + const secondPromise = runtime.createProvider({ productId: "second.dot" }); + + expect(worker.messages.at(-2)).toEqual({ + kind: "createCore", + coreId: 1, + product: { productId: "first.dot" }, + }); + expect(worker.messages.at(-1)).toEqual({ + kind: "createCore", + coreId: 2, + product: { productId: "second.dot" }, + }); + + worker.emit({ kind: "coreReady", coreId: 1 }); + worker.emit({ kind: "coreReady", coreId: 2 }); + const first = await firstPromise; + const second = await secondPromise; + + const firstFrames: Uint8Array[] = []; + const secondFrames: Uint8Array[] = []; + first.subscribe((frame) => firstFrames.push(frame)); + second.subscribe((frame) => secondFrames.push(frame)); + + worker.emit({ kind: "frame", coreId: 2, bytes: new Uint8Array([2]) }); + worker.emit({ kind: "frame", coreId: 1, bytes: new Uint8Array([1]) }); + expect(firstFrames).toEqual([new Uint8Array([1])]); + expect(secondFrames).toEqual([new Uint8Array([2])]); + + first.postMessage(new Uint8Array([9])); + expect(worker.messages.at(-1)).toEqual({ + kind: "frame", + coreId: 1, + bytes: new Uint8Array([9]), + }); + + first.dispose(); + expect(worker.messages.at(-1)).toEqual({ kind: "disposeCore", coreId: 1 }); + + worker.emit({ kind: "frame", coreId: 2, bytes: new Uint8Array([3]) }); + expect(firstFrames).toEqual([new Uint8Array([1])]); + expect(secondFrames).toEqual([new Uint8Array([2]), new Uint8Array([3])]); + + runtime.dispose(); + expect(worker.messages.at(-1)).toEqual({ kind: "dispose" }); + }); + + it("dev global setLogLevel updates every live worker provider", async () => { + const previous = devGlobal.__truapi; + delete devGlobal.__truapi; + const firstWorker = new FakeWorker(); + const secondWorker = new FakeWorker(); + const first = await readyProvider(firstWorker); + const second = await readyProvider(secondWorker); + + devGlobal.__truapi!.setLogLevel("debug"); + + expect(firstWorker.messages.at(-1)).toEqual({ + kind: "setLogLevel", + level: "debug", + }); + expect(secondWorker.messages.at(-1)).toEqual({ + kind: "setLogLevel", + level: "debug", + }); + expect(devGlobal.__truapi!.getLogLevel()).toBe("debug"); + + devGlobal.__truapi!.setLogLevel("off"); + first.dispose(); + second.dispose(); + if (previous === undefined) { + delete devGlobal.__truapi; + } else { + devGlobal.__truapi = previous; + } + }); + + it("dev global setLogLevel applies to providers created later", async () => { + const previous = devGlobal.__truapi; + delete devGlobal.__truapi; + const moduleUrl = `./create-worker-host-runtime.js?dev-global-${Date.now()}`; + const { createWebWorkerPairingHostRuntime: freshCreateWebWorkerPairingHostRuntime } = (await import( + moduleUrl + )) as typeof import("./create-worker-host-runtime.js"); + + expect(typeof devGlobal.__truapi!.setLogLevel).toBe("function"); + devGlobal.__truapi!.setLogLevel("trace"); + + const firstWorker = new FakeWorker(); + const first = await readyProvider(firstWorker, { + createWebWorkerPairingHostRuntime: freshCreateWebWorkerPairingHostRuntime, + }); + first.dispose(); + + const secondWorker = new FakeWorker(); + const second = await readyProvider(secondWorker, { + createWebWorkerPairingHostRuntime: freshCreateWebWorkerPairingHostRuntime, + }); + + expect(secondWorker.messages[0].kind).toBe("init"); + expect(secondWorker.messages[0].logLevel).toBe("trace"); + expect( + secondWorker.messages.some((message) => { + return message.kind === "setLogLevel" && message.level === "trace"; + }), + ).toBe(true); + + second.dispose(); + devGlobal.__truapi!.setLogLevel("off"); + if (previous === undefined) { + delete devGlobal.__truapi; + } else { + devGlobal.__truapi = previous; + } + }); + + it("dev global setLogLevel persists the level to localStorage", async () => { + const previousGlobal = devGlobal.__truapi; + const previousStorage = globalThis.localStorage; + delete devGlobal.__truapi; + const store = new Map(); + globalThis.localStorage = { + getItem: (key: string) => (store.has(key) ? store.get(key)! : null), + setItem: (key: string, value: string) => store.set(key, String(value)), + } as unknown as Storage; + + const worker = new FakeWorker(); + const provider = await readyProvider(worker); + + devGlobal.__truapi!.setLogLevel("debug"); + expect(store.get("truapi:logLevel")).toBe("debug"); + + devGlobal.__truapi!.setLogLevel("off"); + expect(store.get("truapi:logLevel")).toBe("off"); + + provider.dispose(); + globalThis.localStorage = previousStorage; + if (previousGlobal === undefined) { + delete devGlobal.__truapi; + } else { + devGlobal.__truapi = previousGlobal; + } + }); + + it("resolves disconnect responses", async () => { + const worker = new FakeWorker(); + const providerPromise = createProviderFromRuntime(asWorker(worker), makeHostCallbacks(), { + runtimeConfig: runtimeConfig(), + }); + worker.emit({ kind: "loaded" }); + worker.emit({ kind: "ready" }); + const provider = await finishProviderReady(worker, providerPromise); + + const disconnect = provider.disconnectSession(); + const msg = worker.messages.at(-1)!; + expect(msg.kind).toBe("disconnectSession"); + expect(typeof msg.requestId).toBe("number"); + + worker.emit({ + kind: "disconnectSessionResponse", + requestId: msg.requestId, + ok: true, + }); + await disconnect; + + provider.dispose(); + }); + + it("dispatches callback requests to host hooks", async () => { + const worker = new FakeWorker(); + let clears = 0; + const authSessionKey = CoreStorageKey.enc({ tag: "AuthSession" }); + const providerPromise = createProviderFromRuntime( + asWorker(worker), + makeHostCallbacks({ + clearCoreStorage: async (key) => { + expect(key).toEqual({ tag: "AuthSession", value: undefined }); + clears += 1; + }, + }), + { runtimeConfig: runtimeConfig() }, + ); + worker.emit({ kind: "loaded" }); + worker.emit({ kind: "ready" }); + const provider = await finishProviderReady(worker, providerPromise); + + worker.emit({ + kind: "callbackRequest", + requestId: 7, + name: "clearCoreStorage", + args: [authSessionKey], + }); + await settle(); + + expect(clears).toBe(1); + expect(worker.messages.at(-1)).toEqual({ + kind: "callbackResponse", + requestId: 7, + ok: true, + value: undefined, + }); + + provider.dispose(); + }); + + it("reports unknown callback requests", async () => { + const worker = new FakeWorker(); + const providerPromise = createProviderFromRuntime(asWorker(worker), makeHostCallbacks(), { + runtimeConfig: runtimeConfig(), + }); + worker.emit({ kind: "loaded" }); + worker.emit({ kind: "ready" }); + const provider = await finishProviderReady(worker, providerPromise); + + worker.emit({ + kind: "callbackRequest", + requestId: 11, + name: "someFutureCallback", + args: [new Uint8Array([1, 2, 3])], + }); + await settle(); + + expect(worker.messages.at(-1)).toEqual({ + kind: "callbackResponse", + requestId: 11, + ok: false, + error: "unknown callback: someFutureCallback", + }); + + provider.dispose(); + }); + + it("forwards authStateChanged callback requests", async () => { + const worker = new FakeWorker(); + const states: AuthStateValue[] = []; + const providerPromise = createProviderFromRuntime( + asWorker(worker), + makeHostCallbacks({ + authStateChanged: (state) => { + states.push(state); + }, + }), + { runtimeConfig: runtimeConfig() }, + ); + worker.emit({ kind: "loaded" }); + worker.emit({ kind: "ready" }); + const provider = await finishProviderReady(worker, providerPromise); + const publicKey = new Uint8Array(32); + publicKey.set([1, 2]); + + worker.emit({ + kind: "callbackRequest", + requestId: 3, + name: "authStateChanged", + args: [ + AuthState.enc({ + tag: "Connected", + value: { + publicKey, + liteUsername: "alice", + }, + }), + ], + }); + await settle(); + + expect(states).toEqual([ + { + tag: "Connected", + value: { + publicKey, + liteUsername: "alice", + }, + }, + ]); + expect(worker.messages.at(-1)).toEqual({ + kind: "callbackResponse", + requestId: 3, + ok: true, + value: undefined, + }); + + provider.dispose(); + }); + + it("posts cancelPairing to the worker", async () => { + const worker = new FakeWorker(); + const config = runtimeConfig(); + const runtimePromise = createWebWorkerPairingHostRuntime( + asWorker(worker), + makeHostCallbacks(), + { + hostConfig: hostConfigFromRuntimeConfig(config), + }, + ); + worker.emit({ kind: "loaded" }); + worker.emit({ kind: "ready" }); + const runtime = await runtimePromise; + + runtime.cancelPairing(); + + expect(worker.messages.at(-1)).toEqual({ kind: "cancelPairing" }); + runtime.dispose(); + }); + + it("posts notifySessionStoreChanged to the worker", async () => { + const worker = new FakeWorker(); + const config = runtimeConfig(); + const runtimePromise = createWebWorkerPairingHostRuntime( + asWorker(worker), + makeHostCallbacks(), + { + hostConfig: hostConfigFromRuntimeConfig(config), + }, + ); + worker.emit({ kind: "loaded" }); + worker.emit({ kind: "ready" }); + const runtime = await runtimePromise; + + runtime.notifySessionStoreChanged(); + + expect(worker.messages.at(-1)).toEqual({ + kind: "notifySessionStoreChanged", + }); + runtime.dispose(); + }); + + it("worker fault terminates the worker and runs the full teardown", async () => { + const worker = new FakeWorker(); + let subscriptionDisposes = 0; + let chainResponseStops = 0; + let chainCloses = 0; + const providerPromise = createProviderFromRuntime( + asWorker(worker), + makeHostCallbacks({ + // Manual async iterables whose `return()` records disposal; the + // provider disposes subscriptions and closes chain connections + // on a worker fault. + subscribeTheme: () => + ({ + [Symbol.asyncIterator]() { + return this; + }, + next: () => new Promise(() => {}), + return: async () => { + subscriptionDisposes += 1; + return { done: true, value: undefined }; + }, + }) as unknown as AsyncIterable>, + connect: async () => ({ + send() {}, + responses: () => + ({ + [Symbol.asyncIterator]() { + return this; + }, + next: () => new Promise(() => {}), + return: async () => { + chainResponseStops += 1; + return { done: true, value: undefined }; + }, + }) as unknown as AsyncIterable, + close() { + chainCloses += 1; + }, + }), + }), + { runtimeConfig: runtimeConfig() }, + ); + worker.emit({ kind: "loaded" }); + worker.emit({ kind: "ready" }); + const provider = await finishProviderReady(worker, providerPromise); + + worker.emit({ + kind: "subscriptionStart", + subId: 1, + name: "subscribeTheme", + payload: null, + }); + worker.emit({ + kind: "chainConnectStart", + connId: 1, + genesisHash: "0xab", + }); + await settle(); + await settle(); + + const closes: Error[] = []; + provider.subscribeClose!((error) => closes.push(error)); + + worker.emitError("boom"); + await settle(); + await settle(); + + expect(worker.terminated).toBe(true); + expect(subscriptionDisposes).toBe(1); + expect(chainResponseStops).toBe(1); + expect(chainCloses).toBe(1); + expect(closes.length).toBe(1); + expect(closes[0].message).toMatch(/boom/); + + // The fault teardown is terminal; a second fault is a no-op. + worker.emitError("again"); + expect(closes.length).toBe(1); + + let lateClose: Error | null = null; + provider.subscribeClose!((error) => { + lateClose = error; + }); + expect(lateClose).toBeInstanceOf(Error); + expect(lateClose!.message).toMatch(/boom/); + }); + + it("worker fatalError during init rejects provider creation", async () => { + const worker = new FakeWorker(); + const providerPromise = createProviderFromRuntime(asWorker(worker), makeHostCallbacks(), { + runtimeConfig: runtimeConfig(), + }); + + worker.emit({ kind: "fatalError", error: "bad wasm" }); + + await expect(providerPromise).rejects.toThrow(/worker init reported error: bad wasm/); + expect(worker.terminated).toBe(true); + }); + + it("worker frameError after init closes the provider", async () => { + const worker = new FakeWorker(); + const provider = await readyProvider(worker); + const closes: Error[] = []; + provider.subscribeClose!((error) => closes.push(error)); + + worker.emit({ kind: "frameError", coreId: 1, error: "bad frame" }); + + expect(worker.terminated).toBe(false); + expect(worker.messages.at(-1)).toEqual({ kind: "disposeCore", coreId: 1 }); + expect(closes.length).toBe(1); + expect(closes[0].message).toMatch(/worker frame error: bad frame/); + + let lateClose: Error | null = null; + provider.subscribeClose!((error) => { + lateClose = error; + }); + expect(lateClose).toBeInstanceOf(Error); + provider.dispose(); + }); + + it("routes payload-carrying subscriptions by name", async () => { + const worker = new FakeWorker(); + const keys: Uint8Array[] = []; + const providerPromise = createProviderFromRuntime( + asWorker(worker), + makeHostCallbacks({ + lookupPreimage: async function* (key) { + keys.push(key); + yield ok(new Uint8Array([1])); + }, + }), + { runtimeConfig: runtimeConfig() }, + ); + worker.emit({ kind: "loaded" }); + worker.emit({ kind: "ready" }); + const provider = await finishProviderReady(worker, providerPromise); + + worker.emit({ + kind: "subscriptionStart", + subId: 4, + name: "lookupPreimage", + payload: new Uint8Array([9, 9]), + }); + + await settle(); + await settle(); + expect(keys).toEqual([new Uint8Array([9, 9])]); + expect(worker.messages.at(-1)).toEqual({ + kind: "subscriptionItem", + subId: 4, + value: new Uint8Array([1]), + }); + + provider.dispose(); + }); + + it("never falls through unknown subscription names to another callback", async () => { + const worker = new FakeWorker(); + let preimageStarts = 0; + const providerPromise = createProviderFromRuntime( + asWorker(worker), + makeHostCallbacks({ + lookupPreimage: (() => { + preimageStarts += 1; + return () => {}; + }) as unknown as PreimageHost["lookupPreimage"], + }), + { runtimeConfig: runtimeConfig() }, + ); + worker.emit({ kind: "loaded" }); + worker.emit({ kind: "ready" }); + const provider = await finishProviderReady(worker, providerPromise); + + worker.emit({ + kind: "subscriptionStart", + subId: 5, + name: "someFutureSubscribe", + payload: new Uint8Array([1, 2, 3]), + }); + + expect(preimageStarts).toBe(0); + expect(worker.messages.some((m) => m.kind === "subscriptionItem")).toBe(false); + + provider.dispose(); + }); + + it("does not dispatch a payload-carrying subscription without payload", async () => { + const worker = new FakeWorker(); + let preimageStarts = 0; + const providerPromise = createProviderFromRuntime( + asWorker(worker), + makeHostCallbacks({ + lookupPreimage: (() => { + preimageStarts += 1; + return () => {}; + }) as unknown as PreimageHost["lookupPreimage"], + }), + { runtimeConfig: runtimeConfig() }, + ); + worker.emit({ kind: "loaded" }); + worker.emit({ kind: "ready" }); + const provider = await finishProviderReady(worker, providerPromise); + + worker.emit({ + kind: "subscriptionStart", + subId: 6, + name: "lookupPreimage", + payload: null, + }); + + expect(preimageStarts).toBe(0); + + provider.dispose(); + }); + + it("rejects when init times out", async () => { + const worker = new FakeWorker(); + const providerPromise = createProviderFromRuntime(asWorker(worker), makeHostCallbacks(), { + runtimeConfig: runtimeConfig(), + initTimeoutMs: 20, + }); + worker.emit({ kind: "loaded" }); + await expect(providerPromise).rejects.toThrow(/worker init timed out after 20ms/); + expect(worker.terminated).toBe(true); + }); + + it("rejects on messageerror during init", async () => { + const worker = new FakeWorker(); + const providerPromise = createProviderFromRuntime(asWorker(worker), makeHostCallbacks(), { + runtimeConfig: runtimeConfig(), + }); + worker.emitMessageError(); + await expect(providerPromise).rejects.toThrow(/could not be deserialized/); + expect(worker.terminated).toBe(true); + }); + + it("decodes raw v01 push notification payloads", async () => { + let notification: HostPushNotificationRequest | undefined; + const callbacks = createWasmRawCallbacks( + makeHostCallbacks({ + pushNotification: async (request) => { + notification = request; + return { id: 42 }; + }, + }), + ); + + const encoded = await callbacks.pushNotification!( + HostPushNotificationRequest.enc({ + text: "Hello!", + deeplink: undefined, + scheduledAt: undefined, + }), + ); + + expect(HostPushNotificationResponse.dec(encoded).id).toBe(42); + expect(notification).toEqual({ + text: "Hello!", + deeplink: undefined, + scheduledAt: undefined, + }); + }); +}); diff --git a/js/packages/truapi-host-wasm/src/worker-protocol.ts b/js/packages/truapi-host-wasm/src/worker-protocol.ts new file mode 100644 index 00000000..2b1a73c2 --- /dev/null +++ b/js/packages/truapi-host-wasm/src/worker-protocol.ts @@ -0,0 +1,164 @@ +// Wire format between the main thread (`createWebWorkerPairingHostRuntime`) and the +// Web Worker that hosts the truapi-server WASM runtime. +// +// Main window / host JS +// ┌─────────────────────────────────────────────────────────────────┐ +// │ createWebWorkerPairingHostRuntime │ +// │ host callbacks: storage, DOM prompts, chain provider, logging │ +// └───────────────┬─────────────────────────────────────────────────┘ +// │ MainToWorker: init, createCore, frame, +// │ callbackResponse, subscriptionItem, +// │ chainResponse +// v +// Dedicated Worker +// ┌─────────────────────────────────────────────────────────────────┐ +// │ shared truapi-server WASM PairingHostRuntime + product runtimes │ +// │ generated raw-callback proxy │ +// └───────────────┬─────────────────────────────────────────────────┘ +// │ WorkerToMain: coreReady, frame, callbackRequest, +// │ subscriptionStart, chainConnect +// v +// Main window dispatches those requests to the actual host callbacks. +// +// Frames (`kind: 'frame'`) carry SCALE-encoded `ProtocolMessage` bytes +// untouched in either direction. Everything else is a control message +// for callback dispatch, subscription bookkeeping, or chain connections. +// +// Frame bytes cross the boundary by structured clone, deliberately not as +// transferables: the sender keeps using its buffer (the worker side posts +// views into WASM memory) and frames are small, so the copy is the simpler +// safe choice. + +import type { LogLevel, PermissionAuthorizationStatus } from "./runtime.js"; +import type { + CallbackName, + SubscriptionName, +} from "./generated/worker-callbacks.js"; +/** + * Generated callback-name unions used by the worker transport. They keep the + * hand-written protocol aligned with the Rust platform callback catalog. + */ +export type { + CallbackName, + SubscriptionName, +} from "./generated/worker-callbacks.js"; + +/** + * Positional arguments for a callback. The wasm core calls each callback + * at a fixed arity; a uniform `unknown[]` keeps the wire protocol simple. + */ +export type CallbackArgs = readonly unknown[]; + +/** + * Messages posted by the main window to the WASM worker. These either control + * worker/core lifecycle, forward encoded TrUAPI frames into the core, or return + * host callback/subscription/chain responses requested by the worker. + */ +export type MainToWorker = + | { kind: "init"; logLevel: LogLevel; hostConfig: unknown } + | { kind: "createCore"; coreId: number; product: unknown } + | { kind: "disposeCore"; coreId: number } + | { kind: "setLogLevel"; level: LogLevel } + | { kind: "frame"; coreId: number; bytes: Uint8Array } + | { kind: "disconnectSession"; requestId: number } + | { kind: "cancelPairing" } + | { kind: "notifySessionStoreChanged" } + | { + kind: "getPermissionAuthorizationStatus"; + productId: string; + requestId: number; + request: Uint8Array; + } + | { + kind: "getPermissionAuthorizationStatuses"; + productId: string; + requestId: number; + requests: Uint8Array[]; + } + | { + kind: "setPermissionAuthorizationStatus"; + productId: string; + requestId: number; + request: Uint8Array; + status: PermissionAuthorizationStatus; + } + | { kind: "callbackResponse"; requestId: number; ok: true; value: unknown } + | { kind: "callbackResponse"; requestId: number; ok: false; error: string } + | { kind: "subscriptionItem"; subId: number; value: unknown } + | { kind: "chainConnectAck"; connId: number; ok: true } + | { kind: "chainConnectAck"; connId: number; ok: false; error: string } + | { kind: "chainResponse"; connId: number; json: string } + | { kind: "dispose" }; + +/** + * Messages posted by the WASM worker back to the main window. These either + * report worker lifecycle/errors, emit encoded TrUAPI frames from the core, or + * request host callbacks, subscriptions, and chain-provider operations. + */ +export type WorkerToMain = + | { kind: "loaded" } + | { kind: "ready" } + | { kind: "coreReady"; coreId: number } + | { kind: "coreError"; coreId: number; error: string } + | { kind: "fatalError"; error: string } + | { kind: "frameError"; coreId: number; error: string } + | { kind: "disposeError"; error: string } + | { kind: "frame"; coreId: number; bytes: Uint8Array } + | { kind: "disconnectSessionResponse"; requestId: number; ok: true } + | { + kind: "disconnectSessionResponse"; + requestId: number; + ok: false; + error: string; + } + | { + kind: "permissionAuthorizationStatusResponse"; + requestId: number; + ok: true; + status: PermissionAuthorizationStatus; + } + | { + kind: "permissionAuthorizationStatusResponse"; + requestId: number; + ok: false; + error: string; + } + | { + kind: "permissionAuthorizationStatusesResponse"; + requestId: number; + ok: true; + statuses: PermissionAuthorizationStatus[]; + } + | { + kind: "permissionAuthorizationStatusesResponse"; + requestId: number; + ok: false; + error: string; + } + | { + kind: "setPermissionAuthorizationStatusResponse"; + requestId: number; + ok: true; + } + | { + kind: "setPermissionAuthorizationStatusResponse"; + requestId: number; + ok: false; + error: string; + } + | { + kind: "callbackRequest"; + requestId: number; + name: CallbackName; + args: CallbackArgs; + } + | { + kind: "subscriptionStart"; + subId: number; + name: SubscriptionName; + payload: Uint8Array | null; + } + | { kind: "subscriptionStop"; subId: number } + | { kind: "chainConnectStart"; connId: number; genesisHash: string } + | { kind: "chainSend"; connId: number; request: string } + | { kind: "chainClose"; connId: number }; diff --git a/js/packages/truapi-host-wasm/src/worker-runtime.ts b/js/packages/truapi-host-wasm/src/worker-runtime.ts new file mode 100644 index 00000000..a5e071f1 --- /dev/null +++ b/js/packages/truapi-host-wasm/src/worker-runtime.ts @@ -0,0 +1,513 @@ +/// +// Worker entrypoint. Loads the web-targeted truapi-server WASM bundle and +// bridges every host callback over postMessage. The main thread keeps the +// state that needs DOM access (localStorage, prompts) while the core dispatcher +// runs here off the page main thread. + +import type { + MainToWorker, + SubscriptionName, + WorkerToMain, +} from "./worker-protocol.js"; +import { + createWorkerRawCallbacks, + type CallbackName, +} from "./generated/worker-callbacks.js"; +import { errorMessage } from "./error.js"; + +type PermissionAuthorizationStatus = "NotDetermined" | "Denied" | "Authorized"; + +interface WorkerProductRuntime { + receiveFrame(frame: Uint8Array): Promise; + dispose(): void; + free(): void; +} + +interface WorkerPairingHostRuntime { + productRuntime( + product: unknown, + coreCallbacks: unknown, + ): WorkerProductRuntime; + disconnectSession(): Promise; + cancelPairing(): void; + notifySessionStoreChanged(): void; + permissionAuthorizationStatus( + productId: string, + request: Uint8Array, + ): Promise; + permissionAuthorizationStatuses( + productId: string, + requests: Uint8Array[], + ): Promise; + setPermissionAuthorizationStatus( + productId: string, + request: Uint8Array, + status: string, + ): Promise; + free(): void; +} + +interface WasmModuleShape { + default: (input?: unknown) => Promise; + WasmPairingHostRuntime: new ( + callbacks: unknown, + hostConfig: unknown, + ) => WorkerPairingHostRuntime; + WasmProductRuntime: new ( + callbacks: unknown, + runtimeConfig: unknown, + ) => WorkerProductRuntime; + setLogLevel?: (level: string) => void; +} + +// Resolved at runtime, the wasm-pack artifact lives outside `src/` so a +// static import would leak into the TS rootDir. The relative path is +// resolved against `dist/worker-runtime.js` once compiled. Indirected +// through a variable so TS skips the static module-existence check. +const WASM_WEB_PATH = "./wasm/web/truapi_server.js"; +const wasmModulePromise = import( + /* @vite-ignore */ WASM_WEB_PATH +) as Promise; + +const ctx = self as unknown as DedicatedWorkerGlobalScope; + +function postToMain(msg: WorkerToMain): void { + ctx.postMessage(msg); +} + +let nextRequestId = 0; +const pendingCallbacks = new Map< + number, + (result: { ok: true; value: unknown } | { ok: false; error: string }) => void +>(); + +let nextSubId = 0; +const subscriptionItemListeners = new Map void>(); + +let nextConnId = 0; +type ChainConnectAck = { ok: true } | { ok: false; error: string }; +const chainConnectAcks = new Map void>(); +const chainResponseListeners = new Map void>(); + +function callbackRequest( + name: CallbackName, + args: readonly unknown[], +): Promise { + return new Promise((resolve, reject) => { + const requestId = ++nextRequestId; + pendingCallbacks.set(requestId, (r) => { + if (r.ok) resolve(r.value); + else reject(new Error(r.error)); + }); + postToMain({ kind: "callbackRequest", requestId, name, args }); + }); +} + +function startSubscription( + name: SubscriptionName, + payload: Uint8Array | null, + sendItem: (value: T) => void, +): () => void { + const subId = ++nextSubId; + subscriptionItemListeners.set(subId, sendItem as (value: unknown) => void); + postToMain({ kind: "subscriptionStart", subId, name, payload }); + return () => { + subscriptionItemListeners.delete(subId); + postToMain({ kind: "subscriptionStop", subId }); + }; +} + +interface WorkerChainConnection { + send(request: string): void; + close(): void; +} + +/** + * Worker-side half of the host chain-connect bridge. + * + * The Rust core runs in this worker but owns no socket. When it needs chain + * access (chainHead v1 for People-chain identity / statement-store SSO) it + * calls this; the actual transport lives on the host main thread and is reached + * over postMessage. The data crossing here is JSON-RPC strings, not SCALE: only + * the product<->core wire is SCALE. + * + * per-tab / sandboxed core-owned (this Web Worker) host-owned (main thread) + * +-------------------+ SCALE +--------------------------+ +--------------------------------+ + * | Product (iframe) |<------->| truapi-server WASM core | | host.connect() (ChainProvider) | + * | speaks TrUAPI | frames | chainHead v1, SSO, | | host-owned JSON-RPC transport | + * | never sees chains | | People-chain identity | | remote RPC, native client, ... | + * +-------------------+ +--------------------------+ +--------------------------------+ + * | ^ JSON-RPC strings (not SCALE) ^ | + * chainConnect() | | onResponse(json) connect | | responses() + * (this fn) v | | v + * worker-runtime.ts <======== postMessage ========> create-worker-host-runtime.ts + * chainConnectStart / chainSend / chainClose --> handleChainConnect* -> host.connect() + * chainConnectAck / chainResponse <-- (pumped from connection.responses()) + * + * Allocates a `connId`, posts `chainConnectStart`, and resolves a + * `{ send, close }` handle once the main thread acks. `send` posts `chainSend`, + * `close` posts `chainClose`, and every `chainResponse` for this `connId` is + * delivered to `onResponse`. + */ +function chainConnect( + genesisHash: string, + onResponse: (json: string) => void, +): Promise { + const connId = ++nextConnId; + return new Promise((resolve, reject) => { + chainConnectAcks.set(connId, (ack) => { + if (!ack.ok) { + chainResponseListeners.delete(connId); + reject(new Error(ack.error)); + return; + } + resolve({ + send(request: string) { + postToMain({ kind: "chainSend", connId, request }); + }, + close() { + chainResponseListeners.delete(connId); + postToMain({ kind: "chainClose", connId }); + }, + }); + }); + chainResponseListeners.set(connId, onResponse); + postToMain({ kind: "chainConnectStart", connId, genesisHash }); + }); +} + +/** Build the host-level callback object passed to the WASM runtime. */ +function buildRawCallbacks() { + return createWorkerRawCallbacks({ + callbackRequest, + startSubscription, + chainConnect, + }); +} + +function buildCoreCallbacks(coreId: number) { + return { + emitFrame(frame: Uint8Array): void { + postToMain({ kind: "frame", coreId, bytes: frame }); + }, + dispose(): void { + // Main thread owns lifecycle and disposes explicitly. + }, + }; +} + +let runtime: WorkerPairingHostRuntime | null = null; +const cores = new Map(); +let wasm: WasmModuleShape | null = null; + +(async () => { + try { + wasm = await wasmModulePromise; + await wasm.default(); + postToMain({ kind: "loaded" }); + } catch (err) { + postToMain({ kind: "fatalError", error: errorMessage(err) }); + } +})(); + +ctx.addEventListener("message", (ev: MessageEvent) => { + const msg = ev.data; + switch (msg.kind) { + case "init": + if (!wasm) { + postToMain({ + kind: "fatalError", + error: "init received before WASM loaded", + }); + break; + } + if (runtime) { + postToMain({ + kind: "fatalError", + error: "init: runtime already initialized", + }); + break; + } + wasm.setLogLevel?.(msg.logLevel); + try { + runtime = new wasm.WasmPairingHostRuntime( + buildRawCallbacks(), + msg.hostConfig, + ); + postToMain({ kind: "ready" }); + } catch (err) { + postToMain({ kind: "fatalError", error: `init: ${errorMessage(err)}` }); + } + break; + case "createCore": + if (!runtime) { + postToMain({ + kind: "coreError", + coreId: msg.coreId, + error: "createCore received before runtime is ready", + }); + break; + } + try { + const core = runtime.productRuntime( + msg.product, + buildCoreCallbacks(msg.coreId), + ); + cores.set(msg.coreId, core); + postToMain({ kind: "coreReady", coreId: msg.coreId }); + } catch (err) { + postToMain({ + kind: "coreError", + coreId: msg.coreId, + error: errorMessage(err), + }); + } + break; + case "setLogLevel": + wasm?.setLogLevel?.(msg.level); + break; + case "frame": + void handleFrame(msg.coreId, msg.bytes); + break; + case "disconnectSession": + void handleDisconnectSession(msg.requestId); + break; + case "cancelPairing": + runtime?.cancelPairing(); + break; + case "notifySessionStoreChanged": + runtime?.notifySessionStoreChanged(); + break; + case "getPermissionAuthorizationStatus": + void handleGetPermissionAuthorizationStatus( + msg.productId, + msg.requestId, + msg.request, + ); + break; + case "getPermissionAuthorizationStatuses": + void handleGetPermissionAuthorizationStatuses( + msg.productId, + msg.requestId, + msg.requests, + ); + break; + case "setPermissionAuthorizationStatus": + void handleSetPermissionAuthorizationStatus( + msg.productId, + msg.requestId, + msg.request, + msg.status, + ); + break; + case "callbackResponse": { + const cb = pendingCallbacks.get(msg.requestId); + if (cb) { + pendingCallbacks.delete(msg.requestId); + cb( + msg.ok + ? { ok: true, value: msg.value } + : { ok: false, error: msg.error }, + ); + } + break; + } + case "subscriptionItem": { + const listener = subscriptionItemListeners.get(msg.subId); + if (listener) listener(msg.value); + break; + } + case "chainConnectAck": { + const cb = chainConnectAcks.get(msg.connId); + if (cb) { + chainConnectAcks.delete(msg.connId); + cb(msg.ok ? { ok: true } : { ok: false, error: msg.error }); + } + break; + } + case "chainResponse": { + const listener = chainResponseListeners.get(msg.connId); + if (listener) listener(msg.json); + break; + } + case "disposeCore": + disposeCore(msg.coreId); + break; + case "dispose": + try { + for (const coreId of [...cores.keys()]) { + disposeCore(coreId); + } + runtime?.free(); + } catch (err) { + postToMain({ kind: "disposeError", error: errorMessage(err) }); + } + runtime = null; + break; + default: { + const { kind } = msg as { kind?: unknown }; + console.warn( + `[truapi worker-runtime] unknown message kind: ${String(kind)}`, + ); + } + } +}); + +function disposeCore(coreId: number): void { + const core = cores.get(coreId); + if (!core) return; + cores.delete(coreId); + try { + core.dispose(); + core.free(); + } catch (err) { + postToMain({ kind: "disposeError", error: errorMessage(err) }); + } +} + +async function handleDisconnectSession(requestId: number): Promise { + if (!runtime) { + postToMain({ + kind: "disconnectSessionResponse", + requestId, + ok: false, + error: "disconnectSession received before runtime is ready", + }); + return; + } + try { + await runtime.disconnectSession(); + postToMain({ kind: "disconnectSessionResponse", requestId, ok: true }); + } catch (err) { + postToMain({ + kind: "disconnectSessionResponse", + requestId, + ok: false, + error: errorMessage(err), + }); + } +} + +async function handleGetPermissionAuthorizationStatus( + productId: string, + requestId: number, + request: Uint8Array, +): Promise { + if (!runtime) { + postToMain({ + kind: "permissionAuthorizationStatusResponse", + requestId, + ok: false, + error: "permissionAuthorizationStatus received before runtime is ready", + }); + return; + } + try { + const status = await runtime.permissionAuthorizationStatus( + productId, + request, + ); + postToMain({ + kind: "permissionAuthorizationStatusResponse", + requestId, + ok: true, + status: status as PermissionAuthorizationStatus, + }); + } catch (err) { + postToMain({ + kind: "permissionAuthorizationStatusResponse", + requestId, + ok: false, + error: errorMessage(err), + }); + } +} + +async function handleGetPermissionAuthorizationStatuses( + productId: string, + requestId: number, + requests: Uint8Array[], +): Promise { + if (!runtime) { + postToMain({ + kind: "permissionAuthorizationStatusesResponse", + requestId, + ok: false, + error: "permissionAuthorizationStatuses received before runtime is ready", + }); + return; + } + try { + const statuses = await runtime.permissionAuthorizationStatuses( + productId, + requests, + ); + postToMain({ + kind: "permissionAuthorizationStatusesResponse", + requestId, + ok: true, + statuses: statuses as PermissionAuthorizationStatus[], + }); + } catch (err) { + postToMain({ + kind: "permissionAuthorizationStatusesResponse", + requestId, + ok: false, + error: errorMessage(err), + }); + } +} + +async function handleSetPermissionAuthorizationStatus( + productId: string, + requestId: number, + request: Uint8Array, + status: PermissionAuthorizationStatus, +): Promise { + if (!runtime) { + postToMain({ + kind: "setPermissionAuthorizationStatusResponse", + requestId, + ok: false, + error: + "setPermissionAuthorizationStatus received before runtime is ready", + }); + return; + } + try { + await runtime.setPermissionAuthorizationStatus(productId, request, status); + postToMain({ + kind: "setPermissionAuthorizationStatusResponse", + requestId, + ok: true, + }); + } catch (err) { + postToMain({ + kind: "setPermissionAuthorizationStatusResponse", + requestId, + ok: false, + error: errorMessage(err), + }); + } +} + +async function handleFrame(coreId: number, bytes: Uint8Array): Promise { + const core = cores.get(coreId); + if (!core) { + postToMain({ + kind: "frameError", + coreId, + error: `frame received for unknown core ${coreId}`, + }); + return; + } + try { + await core.receiveFrame(bytes); + } catch (err) { + postToMain({ + kind: "frameError", + coreId, + error: errorMessage(err), + }); + } +} diff --git a/js/packages/truapi-host-wasm/tsconfig.json b/js/packages/truapi-host-wasm/tsconfig.json new file mode 100644 index 00000000..9d2dcad8 --- /dev/null +++ b/js/packages/truapi-host-wasm/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "bundler", + "composite": true, + "declaration": true, + "outDir": "dist", + "rootDir": "src", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "esModuleInterop": true, + "skipLibCheck": true, + "lib": ["ES2022", "DOM", "WebWorker"] + }, + "include": ["src"], + "exclude": ["src/**/*.test.ts", "src/test-support.ts"], + "references": [{ "path": "../truapi" }] +} diff --git a/js/packages/truapi/package.json b/js/packages/truapi/package.json index 6b8e2f61..db961565 100644 --- a/js/packages/truapi/package.json +++ b/js/packages/truapi/package.json @@ -66,7 +66,7 @@ }, "scripts": { "ensure-generated": "./scripts/ensure-generated.sh", - "build": "tsc", + "build": "tsc -b", "prebuild": "npm run ensure-generated", "codegen": "cargo run -p truapi-codegen -- --input ../../../target/doc/truapi.json --output src/generated --playground-output src/playground --explorer-output src/explorer", "typecheck": "npm run build", diff --git a/js/packages/truapi/src/client.ts b/js/packages/truapi/src/client.ts index fc13022d..f45481f4 100644 --- a/js/packages/truapi/src/client.ts +++ b/js/packages/truapi/src/client.ts @@ -13,13 +13,15 @@ import { type WireProvider, } from "./transport.js"; import { + CallError, indexedTaggedUnion, Result, _void, + type CallErrorValue, type Codec, type ResultPayload, } from "./scale.js"; -import { TRUAPI_CODEC_VERSION, TRUAPI_VERSION } from "./generated/client.js"; +import { TRUAPI_CODEC_VERSION } from "./generated/client.js"; import * as T from "./generated/types.js"; import * as W from "./generated/wire-table.js"; @@ -29,13 +31,12 @@ export type { Subscription, TrUApiTransport }; * Version overrides used when constructing a transport. */ export interface CreateTransportOptions { - /** - * Highest TrUAPI protocol version exposed by the transport. - */ - truapiVersion?: number; - /** * SCALE codec version advertised during host handshake negotiation. + * + * @deprecated TODO(shared-core-wire): remove this override with + * `TrUApiTransport.codecVersion` once generated handshake requests use + * `TRUAPI_CODEC_VERSION` directly. */ codecVersion?: number; } @@ -51,7 +52,10 @@ function protocolVersionTag(version: number): `V${number}` { return `V${version}` as `V${number}`; } -type HandshakeResponse = ResultPayload; +type HandshakeResponse = ResultPayload< + undefined, + CallErrorValue +>; const HANDSHAKE_WIRE_VERSION = 1; /** @@ -63,7 +67,7 @@ function handshakeResponseCodec( return indexedTaggedUnion({ [protocolVersionTag(version)]: [ version - 1, - Result(_void, T.HostHandshakeError), + Result(_void, CallError(T.VersionedHostHandshakeError)), ] as const, }) as Codec<{ tag: `V${number}`; value: HandshakeResponse }>; } @@ -90,8 +94,14 @@ function encodeUnsupportedHandshakeResponse(version: number): Uint8Array { value: { success: false, value: { - tag: "UnsupportedProtocolVersion", - value: undefined, + tag: "Domain", + value: { + tag: "V1", + value: { + tag: "UnsupportedProtocolVersion", + value: undefined, + }, + }, }, }, }); @@ -140,7 +150,6 @@ export function createTransport( provider: WireProvider, options: CreateTransportOptions = {}, ): TrUApiTransport { - const truapiVersion = options.truapiVersion ?? TRUAPI_VERSION; const codecVersion = options.codecVersion ?? TRUAPI_CODEC_VERSION; let idCounter = 0; let closedError: Error | null = null; @@ -305,7 +314,6 @@ export function createTransport( } return { - truapiVersion, codecVersion, /** * Send one request frame and resolve with the typed Ok/Err outcome diff --git a/js/packages/truapi/src/sandbox.ts b/js/packages/truapi/src/sandbox.ts index 51861e4e..702cda26 100644 --- a/js/packages/truapi/src/sandbox.ts +++ b/js/packages/truapi/src/sandbox.ts @@ -10,11 +10,7 @@ * @module */ -import { - createIframeProvider, - createMessagePortProvider, - type WireProvider, -} from "./transport.js"; +import { createMessagePortProvider, type WireProvider } from "./transport.js"; import { createTransport } from "./client.js"; import { createClient, type TrUApiClient } from "./generated/index.js"; @@ -62,10 +58,7 @@ export function isCorrectEnvironment(): boolean { } /** - * Origin used as the `targetOrigin` for outbound `postMessage` frames. Frames - * carry signed payloads and account ids, so this fails closed: when no concrete - * origin can be pinned it returns `null` (rather than falling back to `"*"`) and - * provider construction throws. + * Origin used as the `targetOrigin` for iframe bootstrap messages. */ function resolveHostOrigin(): string | null { if (typeof document !== "undefined" && document.referrer) { @@ -80,7 +73,8 @@ function resolveHostOrigin(): string | null { return null; } -const WEBVIEW_PORT_TIMEOUT_MS = 20_000; +const HOST_PORT_TIMEOUT_MS = 20_000; +let iframePortPromise: Promise | null = null; /** * Resolve the host-injected `MessagePort`, polling `window.__HOST_API_PORT__` @@ -93,7 +87,7 @@ const WEBVIEW_PORT_TIMEOUT_MS = 20_000; */ async function waitForWebviewPort( signal?: AbortSignal, - timeoutMs = WEBVIEW_PORT_TIMEOUT_MS, + timeoutMs = HOST_PORT_TIMEOUT_MS, ): Promise { const start = Date.now(); while (Date.now() - start < timeoutMs) { @@ -107,21 +101,86 @@ async function waitForWebviewPort( ); } -/** Build the {@link WireProvider} matching the detected environment (iframe or webview). */ -function createSandboxProvider(): WireProvider { - if (isIframe()) { +/** + * Resolve the iframe `MessagePort` transferred by `createIframeHost`. + */ +function waitForIframePort( + signal?: AbortSignal, + timeoutMs = HOST_PORT_TIMEOUT_MS, +): Promise { + const existing = hostWindow()?.__HOST_API_PORT__; + if (existing) return Promise.resolve(existing); + if (iframePortPromise) return iframePortPromise; + + iframePortPromise = new Promise((resolve, reject) => { + const win = hostWindow(); + if (!win) { + reject(new Error("window is unavailable")); + return; + } + const hostOrigin = resolveHostOrigin(); - if (!hostOrigin) { - throw new Error( - "TrUAPI iframe provider could not resolve the host origin from document.referrer / ancestorOrigins.", + let done = false; + const cleanup = (): void => { + win.removeEventListener("message", onMessage); + signal?.removeEventListener("abort", onAbort); + clearTimeout(timer); + }; + const finish = (result: MessagePort | Error): void => { + if (done) return; + done = true; + cleanup(); + if (result instanceof Error) { + reject(result); + } else { + win.__HOST_API_PORT__ = result; + resolve(result); + } + }; + const onAbort = (): void => { + finish(new Error("waitForIframePort aborted")); + }; + const onMessage = (event: MessageEvent): void => { + if (event.source !== win.parent) return; + if ( + hostOrigin !== null && + event.origin !== hostOrigin && + event.origin !== "null" + ) { + return; + } + if (event.data?.type !== "truapi-init") return; + const [port] = event.ports; + if (!port) { + finish(new Error("truapi-init did not include a MessagePort")); + return; + } + finish(port); + }; + const timer = setTimeout(() => { + finish( + new Error(`Timed out waiting for iframe MessagePort (${timeoutMs}ms)`), ); - } - return createIframeProvider({ target: window.parent, hostOrigin }); - } + }, timeoutMs); + + win.addEventListener("message", onMessage); + signal?.addEventListener("abort", onAbort, { once: true }); + win.parent.postMessage({ type: "truapi-ready" }, hostOrigin ?? "*"); + }).catch((error: unknown) => { + iframePortPromise = null; + throw error; + }); + + return iframePortPromise; +} + +/** Build the {@link WireProvider} matching the detected environment (iframe or webview). */ +function createSandboxProvider(): WireProvider { const portController = new AbortController(); - const provider = createMessagePortProvider( - waitForWebviewPort(portController.signal), - ); + const portPromise = isIframe() + ? waitForIframePort(portController.signal) + : waitForWebviewPort(portController.signal); + const provider = createMessagePortProvider(portPromise); const baseDispose = provider.dispose; provider.dispose = () => { portController.abort(); @@ -166,14 +225,21 @@ export function getClientSync(): TrUApiClient | null { export function subscribeConnectionStatus( callback: (status: ConnectionStatus) => void, ): () => void { - statusListeners.add(callback); - callback(status); + let emitted = false; + const listener = (next: ConnectionStatus) => { + emitted = true; + callback(next); + }; + statusListeners.add(listener); if (status === "disconnected") { setStatus(getClientSync() ? "connected" : "disconnected"); } + if (!emitted) { + callback(status); + } return () => { - statusListeners.delete(callback); + statusListeners.delete(listener); }; } diff --git a/js/packages/truapi/src/transport.ts b/js/packages/truapi/src/transport.ts index 0f9ad4de..926ec77b 100644 --- a/js/packages/truapi/src/transport.ts +++ b/js/packages/truapi/src/transport.ts @@ -201,12 +201,11 @@ export interface SubscribeRawParams { **/ export interface TrUApiTransport { /** - * Highest TrUAPI protocol version supported by this generated client. - **/ - readonly truapiVersion: number; - - /** - * SCALE codec version negotiated through the handshake. + * SCALE codec version used by generated handshake calls. + * + * @deprecated TODO(shared-core-wire): remove this public transport field once + * generated handshake requests read `TRUAPI_CODEC_VERSION` directly instead + * of going through transport state. **/ readonly codecVersion: number; diff --git a/js/packages/truapi/tsconfig.json b/js/packages/truapi/tsconfig.json index 79d35ea5..56a11d6f 100644 --- a/js/packages/truapi/tsconfig.json +++ b/js/packages/truapi/tsconfig.json @@ -3,6 +3,7 @@ "target": "ES2022", "module": "ES2022", "moduleResolution": "bundler", + "composite": true, "declaration": true, "outDir": "dist", "rootDir": "src", diff --git a/package-lock.json b/package-lock.json index 51905f9a..36199f2f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,6 +30,19 @@ "typescript": "^6.0" } }, + "js/packages/truapi-host-wasm": { + "name": "@parity/truapi-host-wasm", + "version": "0.1.0", + "license": "MIT", + "dependencies": { + "@parity/truapi": "file:../truapi" + }, + "devDependencies": { + "@types/bun": "^1.3.0", + "neverthrow": "^8.2.0", + "typescript": "^5.7" + } + }, "js/packages/truapi/node_modules/typescript": { "version": "6.0.3", "dev": true, @@ -414,6 +427,10 @@ "resolved": "js/packages/truapi", "link": true }, + "node_modules/@parity/truapi-host-wasm": { + "resolved": "js/packages/truapi-host-wasm", + "link": true + }, "node_modules/@rollup/rollup-linux-x64-gnu": { "version": "4.62.2", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", @@ -1173,6 +1190,20 @@ "node": ">=8.0" } }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, "node_modules/undici-types": { "version": "8.3.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", diff --git a/playground/package.json b/playground/package.json index 78fef5f3..488f9fc5 100644 --- a/playground/package.json +++ b/playground/package.json @@ -10,7 +10,7 @@ "prebuild": "node ./scripts/bundle-rxjs-dts.mjs", "build": "next build", "prelint": "node ./scripts/bundle-rxjs-dts.mjs", - "lint": "next lint --dir src --dir test/generated/examples && tsc --noEmit && tsc -p tsconfig.examples.json", + "lint": "eslint src test/generated/examples && tsc --noEmit && tsc -p tsconfig.examples.json", "pretypecheck": "node ./scripts/bundle-rxjs-dts.mjs", "typecheck": "tsc --noEmit", "typecheck:examples": "tsc -p tsconfig.examples.json", diff --git a/playground/playwright.config.ts b/playground/playwright.config.ts index b5f80bfc..9009ec7c 100644 --- a/playground/playwright.config.ts +++ b/playground/playwright.config.ts @@ -11,6 +11,7 @@ export default defineConfig({ reporter: isCI ? [["github"], ["html", { open: "never" }]] : "list", use: { baseURL: "http://localhost:5173", + serviceWorkers: "block", trace: "retain-on-failure", screenshot: "only-on-failure", video: "retain-on-failure", @@ -23,11 +24,13 @@ export default defineConfig({ ], webServer: [ { - // dotli host iframe shell at :5173. `bun run preview` runs - // `turbo run build && bun scripts/preview-server.ts`, so a cold - // CI runner needs the long timeout. - command: "bun run preview", + // dotli host iframe shell at :5173. Localhost product proxy routes are + // debug-build-only, so mirror `make dev` and run the debug preview. + command: "bun run preview:debug", cwd: "../hosts/dotli", + env: { + VITE_NETWORKS: process.env.VITE_NETWORKS ?? "paseo-next-v2,previewnet", + }, url: "http://localhost:5173", reuseExistingServer: !isCI, timeout: 10 * 60 * 1000, diff --git a/playground/src/lib/auto-test.ts b/playground/src/lib/auto-test.ts index 617072d8..5077fb1b 100644 --- a/playground/src/lib/auto-test.ts +++ b/playground/src/lib/auto-test.ts @@ -17,7 +17,9 @@ const SIGNING_TIMEOUT_MS = 30_000; const SSO_TIMEOUT_MS = 60_000; // Services skipped wholesale in the diagnosis until hosts wire them up. -const SKIPPED_SERVICES = new Set(["Coin Payment"]); +const SKIPPED_SERVICES = new Set(["Chat", "Coin Payment", "Payment"]); +// Individual methods skipped while the host surface is intentionally deferred. +const SKIPPED_METHODS = new Set(["Account/create_account_proof"]); // Methods whose first call implicitly triggers a host permission/signing // prompt, so they need the longer signing-class timeout to allow for the user // to respond. `get_account_alias` and `Preimage/submit` prompt on first use. @@ -35,6 +37,10 @@ const LONG_TIMEOUT_METHODS = new Set([ const METHOD_TIMEOUT_MS = new Map([ ["Account/get_account_alias", SSO_TIMEOUT_MS], + ["Resource Allocation/request", SSO_TIMEOUT_MS], + ["Preimage/lookup_subscribe", SSO_TIMEOUT_MS], + ["Preimage/submit", SSO_TIMEOUT_MS], + ["Signing/create_transaction", SSO_TIMEOUT_MS], ]); type RunOneOpts = { @@ -54,6 +60,10 @@ async function runOne({ onUpdate(id, { status: "skipped" }); return; } + if (SKIPPED_METHODS.has(id)) { + onUpdate(id, { status: "skipped" }); + return; + } if (!method.exampleSource) { onUpdate(id, { status: "fail", output: "no runnable example" }); return; diff --git a/playground/tests/e2e/helpers.ts b/playground/tests/e2e/helpers.ts index a5cc3801..9a6dee31 100644 --- a/playground/tests/e2e/helpers.ts +++ b/playground/tests/e2e/helpers.ts @@ -8,6 +8,20 @@ import { expect, type FrameLocator, type Page } from "@playwright/test"; * iframe so individual specs only need to know about playground selectors. */ export async function openPlaygroundInDotli(page: Page): Promise { + await page.addInitScript(() => { + localStorage.setItem("dotli:mode", "gateway"); + localStorage.setItem("dotli:chain-backend", "rpc"); + localStorage.setItem("dotli:content-backend", "ipfs-gateway"); + localStorage.setItem( + "dotli:permissions:localhost:3000", + JSON.stringify({ Camera: "granted" }), + ); + localStorage.setItem("desktop-banner-dismissed", "1"); + localStorage.setItem("truapi:playground:e2e", "1"); + ( + window as typeof window & { __TRUAPI_PLAYGROUND_E2E__?: boolean } + ).__TRUAPI_PLAYGROUND_E2E__ = true; + }); await page.goto("/localhost:3000"); // dotli renders an additional hidden iframe (host.localhost:5173?mode=direct) // alongside the proxied playground; scope to the playground src so the diff --git a/rust/crates/truapi-codegen/src/main.rs b/rust/crates/truapi-codegen/src/main.rs index 42a5aa1e..d4927a61 100644 --- a/rust/crates/truapi-codegen/src/main.rs +++ b/rust/crates/truapi-codegen/src/main.rs @@ -4,6 +4,7 @@ use std::path::PathBuf; use std::str::FromStr; mod platform; +mod platform_callbacks; mod rust; mod rustdoc; mod ts; @@ -72,6 +73,11 @@ struct Cli { #[arg(long)] platform_wasm_adapter_output: Option, + /// Output directory for the generated Rust WASM platform bridge. + /// Only honored when `--platform-input` is also set. + #[arg(long)] + platform_rust_output: Option, + /// Output directory for generated explorer metadata (optional). When set, /// writes `codegen/types.ts` with the DataType list consumed by the /// explorer site. @@ -145,33 +151,44 @@ fn main() -> Result<()> { .with_context(|| format!("writing Rust dispatcher to {}", path.display()))?; println!("Wrote Rust dispatcher to {}", path.display()); } - if let (Some(input), Some(output)) = (&cli.platform_input, &cli.platform_ts_output) { + if let Some(input) = &cli.platform_input { + if cli.platform_wasm_adapter_output.is_some() && cli.platform_ts_output.is_none() { + anyhow::bail!("--platform-wasm-adapter-output requires --platform-ts-output"); + } let json = std::fs::read_to_string(input) .with_context(|| format!("reading platform rustdoc JSON from {input}"))?; let krate = rustdoc::parse(&json).with_context(|| format!("parsing platform rustdoc {input}"))?; let definition = platform::extract(&krate) .with_context(|| format!("extracting platform definition from {input}"))?; - let codec_types = api - .types - .iter() - .filter(|t| !matches!(t.kind, rustdoc::TypeDefKind::Alias(_))) - .map(|t| t.name.clone()) - .collect(); - let adapter_output = cli - .platform_wasm_adapter_output - .as_deref() - .unwrap_or(output.as_str()); - ts::generate_host_callbacks(&definition, &codec_types, output, adapter_output) - .with_context(|| format!("writing host callbacks TS to {output}"))?; - println!("Generated typed HostCallbacks TS surface in {output}"); - println!("Generated WASM HostCallbacks adapter in {adapter_output}"); - } else if cli.platform_input.is_some() != cli.platform_ts_output.is_some() + if let Some(output) = &cli.platform_ts_output { + let codec_types = api + .types + .iter() + .filter(|t| !matches!(t.kind, rustdoc::TypeDefKind::Alias(_))) + .map(|t| t.name.clone()) + .collect(); + let adapter_output = cli + .platform_wasm_adapter_output + .as_deref() + .unwrap_or(output.as_str()); + ts::generate_host_callbacks(&definition, &codec_types, output, adapter_output) + .with_context(|| format!("writing host callbacks TS to {output}"))?; + println!("Generated typed HostCallbacks TS surface in {output}"); + println!("Generated WASM HostCallbacks adapter in {adapter_output}"); + } + if let Some(output) = &cli.platform_rust_output { + rust::generate_wasm_bridge_file(&definition, &api, output) + .with_context(|| format!("writing Rust WASM bridge to {}", output.display()))?; + println!("Generated Rust WASM bridge in {}", output.display()); + } + } else if cli.platform_ts_output.is_some() || cli.platform_wasm_adapter_output.is_some() + || cli.platform_rust_output.is_some() { anyhow::bail!( - "--platform-input and --platform-ts-output must be provided together; \ - --platform-wasm-adapter-output additionally requires both" + "--platform-input is required for platform output; \ + --platform-wasm-adapter-output additionally requires --platform-ts-output" ); } if let Some(path) = &cli.explorer_output { diff --git a/rust/crates/truapi-codegen/src/platform_callbacks.rs b/rust/crates/truapi-codegen/src/platform_callbacks.rs new file mode 100644 index 00000000..98a33f65 --- /dev/null +++ b/rust/crates/truapi-codegen/src/platform_callbacks.rs @@ -0,0 +1,181 @@ +//! Shared callback naming and selection rules for generated platform bridges. + +use std::collections::BTreeSet; + +use crate::platform::{PlatformDefinition, PlatformInner, PlatformMethod, PlatformTrait}; +use crate::rustdoc::TypeRef; + +pub(crate) fn composed_traits(definition: &PlatformDefinition) -> Vec<&PlatformTrait> { + let composed: BTreeSet = match &definition.super_trait { + Some(s) => s.composes.iter().cloned().collect(), + None => definition.traits.iter().map(|t| t.name.clone()).collect(), + }; + definition + .traits + .iter() + .filter(|t| composed.contains(&t.name)) + .collect() +} + +pub(crate) fn raw_callback_name(method: &PlatformMethod) -> String { + to_camel_case(&method.name) +} + +pub(crate) fn platform_trait_names(definition: &PlatformDefinition) -> BTreeSet { + definition.traits.iter().map(|t| t.name.clone()).collect() +} + +pub(crate) fn trait_object_return_name<'a>( + method: &'a PlatformMethod, + platform_trait_names: &BTreeSet, +) -> Option<&'a str> { + match &method.return_shape.inner { + PlatformInner::TraitObject(name) => Some(name.as_str()), + PlatformInner::Result { ok, .. } | PlatformInner::Plain(ok) => { + named_platform_trait(ok, platform_trait_names) + } + PlatformInner::Unit | PlatformInner::Stream(_) => None, + } +} + +pub(crate) fn raw_callback_wire_name( + trait_def: &PlatformTrait, + method: &PlatformMethod, + platform_trait_names: &BTreeSet, +) -> String { + let raw = raw_callback_name(method); + if trait_object_return_name(method, platform_trait_names).is_some() { + return format!( + "{}{}", + callback_namespace(&trait_def.name), + upper_first(&raw) + ); + } + raw +} + +pub(crate) fn raw_callback_field_name( + trait_def: &PlatformTrait, + method: &PlatformMethod, + platform_trait_names: &BTreeSet, +) -> String { + snake_case(&raw_callback_wire_name( + trait_def, + method, + platform_trait_names, + )) +} + +pub(crate) fn raw_callback_type_name( + trait_def: &PlatformTrait, + method: &PlatformMethod, + platform_trait_names: &BTreeSet, +) -> String { + upper_first(&raw_callback_wire_name( + trait_def, + method, + platform_trait_names, + )) +} + +pub(crate) fn raw_callback_adapter_name( + trait_def: &PlatformTrait, + method: &PlatformMethod, + platform_trait_names: &BTreeSet, +) -> String { + format!( + "{}Adapter", + raw_callback_wire_name(trait_def, method, platform_trait_names) + ) +} + +pub(crate) fn callback_namespace(trait_name: &str) -> String { + let stem = ["Provider", "Presenter", "Host"] + .into_iter() + .find_map(|suffix| trait_name.strip_suffix(suffix)) + .unwrap_or(trait_name); + lower_pascal_case(stem) +} + +fn named_platform_trait<'a>( + ty: &'a TypeRef, + platform_trait_names: &BTreeSet, +) -> Option<&'a str> { + let TypeRef::Named { name, args } = ty else { + return None; + }; + if args.is_empty() && platform_trait_names.contains(name) { + return Some(name.as_str()); + } + None +} + +/// Unwrap a `Result` stream item to its `T`; other item types pass +/// through. Streams carry `Result`s on the Rust side but the JS raw bridge +/// already unwraps them before handing each item to the WASM callback sink. +pub(crate) fn stream_item(item: &TypeRef) -> &TypeRef { + if let TypeRef::Named { name, args } = item + && name == "Result" + && let Some(ok) = args.first() + { + return ok; + } + item +} + +pub(crate) fn to_camel_case(name: &str) -> String { + let mut out = String::with_capacity(name.len()); + let mut upper_next = false; + for (idx, ch) in name.chars().enumerate() { + if ch == '_' { + upper_next = idx != 0; + continue; + } + if upper_next { + out.extend(ch.to_uppercase()); + upper_next = false; + } else { + out.push(ch); + } + } + out +} + +fn lower_pascal_case(name: &str) -> String { + let mut chars = name.chars(); + let Some(first) = chars.next() else { + return String::new(); + }; + format!( + "{}{}", + first.to_ascii_lowercase(), + chars.collect::() + ) +} + +fn upper_first(name: &str) -> String { + let mut chars = name.chars(); + let Some(first) = chars.next() else { + return String::new(); + }; + format!( + "{}{}", + first.to_ascii_uppercase(), + chars.collect::() + ) +} + +pub(crate) fn snake_case(name: &str) -> String { + let mut out = String::with_capacity(name.len() + 4); + for (idx, ch) in name.chars().enumerate() { + if ch.is_ascii_uppercase() { + if idx != 0 { + out.push('_'); + } + out.push(ch.to_ascii_lowercase()); + } else { + out.push(ch); + } + } + out +} diff --git a/rust/crates/truapi-codegen/src/rust.rs b/rust/crates/truapi-codegen/src/rust.rs index 1ca6f77c..c7878829 100644 --- a/rust/crates/truapi-codegen/src/rust.rs +++ b/rust/crates/truapi-codegen/src/rust.rs @@ -11,12 +11,15 @@ use anyhow::Result; use convert_case::{Case, Casing}; +use crate::platform::PlatformDefinition; use crate::rustdoc::*; mod dispatcher; +mod wasm_bridge; mod wire_table; pub use dispatcher::generate_dispatcher; +pub use wasm_bridge::generate_wasm_bridge; pub use wire_table::generate_wire_table; /// Generates the Rust wire dispatcher and wire-table sources into `output_dir`. @@ -29,6 +32,20 @@ pub fn generate(api: &ApiDefinition, output_dir: &Path) -> Result<()> { Ok(()) } +/// Generates the Rust wasm-bindgen platform bridge source into `output_dir`. +pub fn generate_wasm_bridge_file( + definition: &PlatformDefinition, + api: &ApiDefinition, + output_dir: &Path, +) -> Result<()> { + fs::create_dir_all(output_dir)?; + fs::write( + output_dir.join("generated_bridge.rs"), + generate_wasm_bridge(definition, api)?, + )?; + Ok(()) +} + /// Trait -> versioned-module mapping. Trait names are PascalCase /// (`JsonRpc`, `LocalStorage`); module names are snake_case /// (`jsonrpc`, `local_storage`). The mapping is irregular enough diff --git a/rust/crates/truapi-codegen/src/rust/wasm_bridge.rs b/rust/crates/truapi-codegen/src/rust/wasm_bridge.rs new file mode 100644 index 00000000..d4c18d72 --- /dev/null +++ b/rust/crates/truapi-codegen/src/rust/wasm_bridge.rs @@ -0,0 +1,842 @@ +use std::collections::{BTreeMap, BTreeSet}; +use std::fmt::Write; + +use anyhow::{Result, bail}; +use indoc::{formatdoc, writedoc}; + +use crate::platform::{PlatformDefinition, PlatformInner, PlatformMethod, PlatformTrait}; +use crate::platform_callbacks::{ + composed_traits, platform_trait_names, raw_callback_field_name, raw_callback_name, + raw_callback_wire_name, snake_case, stream_item, trait_object_return_name, +}; +use crate::rustdoc::{ApiDefinition, TypeDef, TypeDefKind, TypeRef, VariantFields}; + +pub fn generate_wasm_bridge( + definition: &PlatformDefinition, + api: &ApiDefinition, +) -> Result { + let ctx = BridgeCtx::new(definition, api); + let traits = composed_traits(definition); + let trait_names = platform_trait_names(definition); + validate_errors(&traits, &ctx)?; + let mut out = String::new(); + writedoc!( + out, + r#" + //! Auto-generated by truapi-codegen. Do not edit. + //! + //! Mechanical wasm-bindgen callback bridge derived from + //! `truapi-platform`. Raw callback names and payload shapes match the + //! generated TypeScript host-callback adapter. + + use futures::stream::BoxStream; + use js_sys::{{Function, Uint8Array}}; + use parity_scale_codec::Encode; + use truapi::v01; + use wasm_bindgen::JsValue; + + use super::{{ + WasmPlatform, call_js_function, decode_bytes, decode_js_item, generic, get_function, + invoke_bool, invoke_bytes_return, invoke_js_subscription, invoke_optional_bytes_return, + invoke_unit, parse_optional_bytes_item, + }}; + + /// JS-side callbacks invoked by the wasm platform bridge. Methods with + /// Rust default bodies are still required here because the generated TS + /// adapter resolves optional host callbacks before constructing this + /// raw callback object. + pub(super) struct JsBridge {{ + "#, + ) + .unwrap(); + + for field in bridge_fields(&traits, &trait_names) { + writeln!(out, " pub(super) {}: Function,", field.field_name).unwrap(); + } + out.push_str("}\n\n"); + + writedoc!( + out, + r#" + impl JsBridge {{ + pub(super) fn from_js(callbacks: &JsValue) -> Result {{ + Ok(Self {{ + "#, + ) + .unwrap(); + for field in bridge_fields(&traits, &trait_names) { + writeln!( + out, + " {}: get_function(callbacks, \"{}\")?,", + field.field_name, field.raw_name + ) + .unwrap(); + } + out.push_str(" })\n }\n}\n\n"); + + let mut parse_fns = BTreeMap::new(); + for trait_def in traits { + if requires_manual_trait_impl(trait_def, &trait_names) { + continue; + } + out.push_str(&emit_trait_impl(trait_def, &ctx, &mut parse_fns)?); + out.push('\n'); + } + for (idx, (_name, body)) in parse_fns.into_iter().enumerate() { + if idx != 0 { + out.push('\n'); + } + out.push_str(body.trim_end()); + out.push('\n'); + } + + Ok(out) +} + +struct BridgeCtx<'a> { + api_types: BTreeMap<&'a str, &'a TypeDef>, + codec_types: BTreeSet<&'a str>, + local_types: BTreeSet<&'a str>, + local_codec_types: BTreeSet<&'a str>, +} + +impl<'a> BridgeCtx<'a> { + fn new(definition: &'a PlatformDefinition, api: &'a ApiDefinition) -> Self { + let api_types = api + .types + .iter() + .map(|ty| (ty.name.as_str(), ty)) + .collect::>(); + let codec_types = api + .types + .iter() + .filter(|ty| !matches!(ty.kind, TypeDefKind::Alias(_))) + .map(|ty| ty.name.as_str()) + .collect::>(); + let local_types = definition + .types + .iter() + .map(|ty| ty.name.as_str()) + .collect::>(); + let local_codec_types = collect_local_bridge_payload_types(definition); + Self { + api_types, + codec_types, + local_types, + local_codec_types, + } + } + + fn is_api_codec(&self, ty: &TypeRef) -> bool { + matches!(ty, TypeRef::Named { name, .. } if self.codec_types.contains(name.as_str())) + } + + fn is_local_codec(&self, ty: &TypeRef) -> bool { + matches!(ty, TypeRef::Named { name, .. } if self.local_codec_types.contains(name.as_str())) + } + + fn alias_primitive(&self, ty: &TypeRef) -> Option<&'a str> { + let TypeRef::Named { name, .. } = ty else { + return None; + }; + let mut seen = BTreeSet::new(); + let mut current = name.as_str(); + loop { + if !seen.insert(current) { + return None; + } + let TypeDefKind::Alias(target) = &self.api_types.get(current)?.kind else { + return None; + }; + match target { + TypeRef::Primitive(primitive) => return Some(primitive.as_str()), + TypeRef::Named { name, .. } => current = name, + _ => return None, + } + } + } +} + +struct BridgeField { + field_name: String, + raw_name: String, +} + +fn bridge_fields( + traits: &[&PlatformTrait], + platform_trait_names: &BTreeSet, +) -> Vec { + let mut fields = Vec::new(); + for trait_def in traits { + for method in &trait_def.methods { + fields.push(BridgeField { + field_name: raw_callback_field_name(trait_def, method, platform_trait_names), + raw_name: raw_callback_wire_name(trait_def, method, platform_trait_names), + }); + } + } + fields +} + +fn requires_manual_trait_impl( + trait_def: &PlatformTrait, + platform_trait_names: &BTreeSet, +) -> bool { + trait_def + .methods + .iter() + .any(|method| trait_object_return_name(method, platform_trait_names).is_some()) +} + +fn emit_trait_impl( + trait_def: &PlatformTrait, + ctx: &BridgeCtx<'_>, + parse_fns: &mut BTreeMap, +) -> Result { + let mut out = String::new(); + if trait_def + .methods + .iter() + .any(|method| method.return_shape.is_async) + { + out.push_str("#[truapi_platform::async_trait]\n"); + } + writeln!( + out, + "impl truapi_platform::{} for WasmPlatform {{", + trait_def.name + ) + .unwrap(); + for (idx, method) in trait_def.methods.iter().enumerate() { + if idx != 0 { + out.push('\n'); + } + out.push_str(&emit_method(method, ctx, parse_fns)?); + } + out.push_str("}\n"); + Ok(out) +} + +fn emit_method( + method: &PlatformMethod, + ctx: &BridgeCtx<'_>, + parse_fns: &mut BTreeMap, +) -> Result { + match &method.return_shape.inner { + PlatformInner::Result { ok, err } => emit_result_method(method, ok, err, ctx), + PlatformInner::Stream(item) => emit_stream_method(method, item, ctx, parse_fns), + PlatformInner::Unit => emit_unit_method(method, ctx), + PlatformInner::Plain(ok) => emit_plain_method(method, ok, ctx), + PlatformInner::TraitObject(_) => bail!( + "trait-object platform method `{}` must be handled manually", + method.name + ), + } +} + +fn emit_result_method( + method: &PlatformMethod, + ok: &TypeRef, + err: &TypeRef, + ctx: &BridgeCtx<'_>, +) -> Result { + let raw = raw_callback_name(method); + let args = js_arg_vec(method, ctx)?; + let ret = format!("Result<{}, {}>", rust_type(ok, ctx)?, rust_type(err, ctx)?); + let params = rust_params(method, ctx)?; + let map_err = error_mapper(err, ctx)?; + + let body = if is_unit(ok) { + await_chain( + &bridge_call("invoke_unit", &method.name, &args, &[]), + &map_err, + ) + } else if is_bool(ok) { + await_chain( + &bridge_call("invoke_bool", &method.name, &args, &[]), + &map_err, + ) + } else if is_bytes(ok) { + await_chain( + &bridge_call("invoke_bytes_return", &method.name, &args, &[]), + &map_err, + ) + } else if is_optional_bytes(ok) { + await_chain( + &bridge_call( + "invoke_optional_bytes_return", + &method.name, + &args, + &[format!( + "{:?}", + format!("{raw} must resolve to Uint8Array, null or undefined") + )], + ), + &map_err, + ) + } else if ctx.is_api_codec(ok) || ctx.is_local_codec(ok) { + formatdoc_decode_result(method, ok, &raw, &args, &map_err, ctx)? + } else { + bail!("unsupported wasm bridge result type for `{raw}`: {ok:?}"); + }; + + Ok(format!( + "{header}\n{body}\n }}\n", + header = method_header("async ", &method.name, ¶ms, Some(&ret)), + body = indent_body(&body, 8), + )) +} + +fn emit_plain_method(method: &PlatformMethod, ok: &TypeRef, ctx: &BridgeCtx<'_>) -> Result { + if !is_unit(ok) { + bail!( + "unsupported non-async plain return for wasm bridge method `{}`", + method.name + ); + } + emit_unit_method(method, ctx) +} + +fn emit_unit_method(method: &PlatformMethod, ctx: &BridgeCtx<'_>) -> Result { + let args = js_arg_vec(method, ctx)?; + let params = rust_params(method, ctx)?; + let body = if method.return_shape.is_async { + format!( + "if let Err(reason) = {}\n .await\n{{\n web_sys::console::error_1(&JsValue::from_str(&reason));\n}}", + bridge_call("invoke_unit", &method.name, &args, &[]) + ) + } else { + let args = if args == "Vec::new()" { + "&[]".to_string() + } else { + format!("&{args}") + }; + format!( + "if let Err(reason) = {} {{\n web_sys::console::error_1(&JsValue::from_str(&reason));\n}}", + bridge_call("call_js_function", &method.name, &args, &[]) + ) + }; + Ok(format!( + "{header}\n{body}\n }}\n", + header = method_header( + if method.return_shape.is_async { + "async " + } else { + "" + }, + &method.name, + ¶ms, + None + ), + body = indent_body(&body, 8), + )) +} + +fn emit_stream_method( + method: &PlatformMethod, + item: &TypeRef, + ctx: &BridgeCtx<'_>, + parse_fns: &mut BTreeMap, +) -> Result { + let raw = raw_callback_name(method); + let TypeRef::Named { name, args } = item else { + bail!("stream `{raw}` must yield Result"); + }; + if name != "Result" || args.len() != 2 { + bail!("stream `{raw}` must yield Result"); + } + let ok = stream_item(item); + let err = &args[1]; + let ret = format!( + "BoxStream<'static, Result<{}, {}>>", + rust_type(ok, ctx)?, + rust_type(err, ctx)? + ); + let params = rust_params(method, ctx)?; + let payload = subscription_payload(method, ctx)?; + let parser = stream_parser(ok, ctx, parse_fns)?; + let body = if payload == "None" { + format!( + "invoke_js_subscription(&self.bridge.{}, None, {parser})", + method.name + ) + } else { + bridge_call( + "invoke_js_subscription", + &method.name, + &payload, + std::slice::from_ref(&parser), + ) + }; + Ok(format!( + "{header}\n{body}\n }}\n", + header = method_header("", &method.name, ¶ms, Some(&ret)), + body = indent_body(&body, 8), + )) +} + +fn formatdoc_decode_result( + method: &PlatformMethod, + ok: &TypeRef, + raw: &str, + args: &str, + map_err: &str, + ctx: &BridgeCtx<'_>, +) -> Result { + let ty = rust_type(ok, ctx)?; + let call = bridge_call("invoke_bytes_return", &method.name, args, &[]); + let await_bytes = await_chain(&call, &format!("{map_err}?")); + Ok(format!( + "let bytes = {await_bytes};\ndecode_bytes::<{ty}>(\n bytes,\n {:?},\n)\n{map_err}", + format!("{raw} response did not decode"), + )) +} + +fn rust_params(method: &PlatformMethod, ctx: &BridgeCtx<'_>) -> Result { + method + .params + .iter() + .map(|param| { + Ok(format!( + "{}: {}", + param.name, + rust_type(¶m.type_ref, ctx)? + )) + }) + .collect::>>() + .map(|params| params.join(", ")) +} + +fn method_header(keyword: &str, name: &str, params: &str, ret: Option<&str>) -> String { + let ret = ret.map(|ret| format!(" -> {ret}")).unwrap_or_default(); + if params.is_empty() { + return format!(" {keyword}fn {name}(&self){ret} {{"); + } + + let one_line = format!(" {keyword}fn {name}(&self, {params}){ret} {{"); + if one_line.len() <= 100 { + return one_line; + } + + let mut out = format!(" {keyword}fn {name}(\n &self,\n"); + for param in params.split(", ") { + writeln!(out, " {param},").unwrap(); + } + write!(out, " ){ret} {{").unwrap(); + out +} + +fn bridge_call(name: &str, field: &str, second_arg: &str, extra_args: &[String]) -> String { + let mut args = vec![format!("&self.bridge.{field}"), second_arg.to_string()]; + args.extend(extra_args.iter().cloned()); + let one_line = format!("{name}({})", args.join(", ")); + if !one_line.contains('\n') && one_line.len() <= 72 { + return one_line; + } + + let mut out = format!("{name}(\n &self.bridge.{field},\n"); + out.push_str(&indent_body(second_arg, 4)); + out.push_str(",\n"); + for arg in extra_args { + writeln!(out, " {arg},").unwrap(); + } + out.push(')'); + out +} + +fn await_chain(call: &str, map_err: &str) -> String { + if call.contains('\n') { + format!("{call}\n.await\n{map_err}") + } else { + format!("{call}\n .await\n {map_err}") + } +} + +fn rust_type(ty: &TypeRef, ctx: &BridgeCtx<'_>) -> Result { + match ty { + TypeRef::Primitive(name) if name == "String" || name == "str" => Ok("String".to_string()), + TypeRef::Primitive(name) => Ok(name.clone()), + TypeRef::Named { name, args } if name == "String" && args.is_empty() => { + Ok("String".to_string()) + } + TypeRef::Named { name, args } if name == "Result" && args.len() == 2 => Ok(format!( + "Result<{}, {}>", + rust_type(&args[0], ctx)?, + rust_type(&args[1], ctx)? + )), + TypeRef::Named { name, args } if ctx.api_types.contains_key(name.as_str()) => { + if args.is_empty() { + Ok(format!("v01::{name}")) + } else { + bail!("generic API type `{name}` is not supported in wasm bridge") + } + } + TypeRef::Named { name, args } if ctx.local_types.contains(name.as_str()) => { + if args.is_empty() { + Ok(format!("truapi_platform::{name}")) + } else { + bail!("generic platform type `{name}` is not supported in wasm bridge") + } + } + TypeRef::Named { name, .. } => Ok(name.clone()), + TypeRef::Vec(inner) if is_u8(inner) => Ok("Vec".to_string()), + TypeRef::Vec(inner) => Ok(format!("Vec<{}>", rust_type(inner, ctx)?)), + TypeRef::Option(inner) => Ok(format!("Option<{}>", rust_type(inner, ctx)?)), + TypeRef::Array(inner, len) => Ok(format!("[{}; {len}]", rust_type(inner, ctx)?)), + TypeRef::Tuple(items) if items.is_empty() => Ok("()".to_string()), + TypeRef::Tuple(items) => Ok(format!( + "({})", + items + .iter() + .map(|item| rust_type(item, ctx)) + .collect::>>()? + .join(", ") + )), + TypeRef::Generic(name) => Ok(name.clone()), + TypeRef::Unit => Ok("()".to_string()), + } +} + +fn js_arg_vec(method: &PlatformMethod, ctx: &BridgeCtx<'_>) -> Result { + let args = method + .params + .iter() + .map(|param| js_arg_expr(¶m.name, ¶m.type_ref, ctx)) + .collect::>>()?; + if args.is_empty() { + Ok("Vec::new()".to_string()) + } else if args.len() == 1 { + Ok(format!("vec![{}]", args[0])) + } else { + let mut out = "vec![\n".to_string(); + for arg in args { + writeln!(out, " {arg},").unwrap(); + } + out.push(']'); + Ok(out) + } +} + +fn js_arg_expr(name: &str, ty: &TypeRef, ctx: &BridgeCtx<'_>) -> Result { + if is_string(ty) { + return Ok(format!("JsValue::from_str(&{name})")); + } + if is_bytes(ty) { + return Ok(format!("Uint8Array::from({name}.as_slice()).into()")); + } + if ctx.is_api_codec(ty) || ctx.is_local_codec(ty) { + return Ok(format!( + "Uint8Array::from({name}.encode().as_slice()).into()" + )); + } + if let Some(primitive) = ctx.alias_primitive(ty) { + return numeric_js_arg(name, primitive); + } + if let TypeRef::Primitive(primitive) = ty { + return numeric_js_arg(name, primitive); + } + bail!("unsupported wasm bridge callback parameter `{name}`: {ty:?}") +} + +fn numeric_js_arg(name: &str, primitive: &str) -> Result { + match primitive { + "u8" | "u16" | "u32" | "i8" | "i16" | "i32" => { + Ok(format!("JsValue::from_f64(f64::from({name}))")) + } + "bool" => Ok(format!("JsValue::from_bool({name})")), + other => bail!("numeric callback parameter `{name}: {other}` is not JS-number safe"), + } +} + +fn subscription_payload(method: &PlatformMethod, ctx: &BridgeCtx<'_>) -> Result { + match method.params.as_slice() { + [] => Ok("None".to_string()), + [param] if is_bytes(¶m.type_ref) => Ok(format!("Some({})", param.name)), + [param] if ctx.is_api_codec(¶m.type_ref) || ctx.is_local_codec(¶m.type_ref) => { + Ok(format!("Some({}.encode())", param.name)) + } + [param] => bail!( + "unsupported subscription payload for `{}`: {:?}", + method.name, + param.type_ref + ), + _ => bail!( + "subscription `{}` has more than one payload parameter", + method.name + ), + } +} + +fn stream_parser( + ok: &TypeRef, + ctx: &BridgeCtx<'_>, + parse_fns: &mut BTreeMap, +) -> Result { + if is_optional_bytes(ok) { + return Ok("parse_optional_bytes_item".to_string()); + } + if ctx.is_api_codec(ok) || ctx.is_local_codec(ok) { + let ty = rust_type(ok, ctx)?; + let label = named_type_name(ok)?; + let fn_name = format!("parse_{}_item", snake_case(label)); + parse_fns.entry(fn_name.clone()).or_insert_with(|| { + formatdoc! { + r#" + fn {fn_name}(value: JsValue) -> Result<{ty}, String> {{ + decode_js_item::<{ty}>(value, "{label}") + }} + "#, + fn_name = fn_name, + ty = ty, + label = label, + } + }); + return Ok(fn_name); + } + bail!("unsupported stream item type: {ok:?}") +} + +fn error_mapper(err: &TypeRef, ctx: &BridgeCtx<'_>) -> Result { + let name = named_type_name(err)?; + if name == "GenericError" { + return Ok(".map_err(generic)".to_string()); + } + let err_ty = rust_type(err, ctx)?; + Ok(format!(".map_err(|reason| {err_ty}::Unknown {{ reason }})")) +} + +fn validate_errors(traits: &[&PlatformTrait], ctx: &BridgeCtx<'_>) -> Result<()> { + for trait_def in traits { + for method in &trait_def.methods { + match &method.return_shape.inner { + PlatformInner::Result { err, .. } => validate_error_type(err, ctx)?, + PlatformInner::Stream(item) => { + let TypeRef::Named { name, args } = item else { + bail!("stream `{}` must yield Result", method.name); + }; + if name == "Result" && args.len() == 2 { + validate_error_type(&args[1], ctx)?; + } + } + PlatformInner::Unit | PlatformInner::Plain(_) | PlatformInner::TraitObject(_) => {} + } + } + } + Ok(()) +} + +fn validate_error_type(err: &TypeRef, ctx: &BridgeCtx<'_>) -> Result<()> { + let name = named_type_name(err)?; + if name == "GenericError" { + return Ok(()); + } + let mut seen = BTreeSet::new(); + validate_error_name(name, ctx, &mut seen) +} + +fn validate_error_name<'a>( + name: &'a str, + ctx: &BridgeCtx<'a>, + seen: &mut BTreeSet<&'a str>, +) -> Result<()> { + if name == "GenericError" { + return Ok(()); + } + if !seen.insert(name) { + bail!("platform error type `{name}` contains a recursive alias/envelope"); + } + let Some(type_def) = resolve_alias_type(name, ctx) else { + bail!("platform error type `{name}` is not present in the API definition"); + }; + let TypeDefKind::Enum(variants) = &type_def.kind else { + bail!("platform error type `{name}` must resolve to an enum"); + }; + let has_unknown_reason = variants.iter().any(|variant| { + variant.name == "Unknown" + && matches!( + &variant.fields, + VariantFields::Named(fields) + if fields.iter().any(|field| { + field.name == "reason" && is_string(&field.type_ref) + }) + ) + }); + if !has_unknown_reason { + let versioned_payloads = variants + .iter() + .filter_map(|variant| match &variant.fields { + VariantFields::Unnamed(types) if types.len() == 1 => Some(&types[0]), + _ => None, + }) + .collect::>(); + if !versioned_payloads.is_empty() && versioned_payloads.len() == variants.len() { + for payload in versioned_payloads { + validate_error_name(named_type_name(payload)?, ctx, seen)?; + } + return Ok(()); + } + bail!("platform error type `{name}` must define `Unknown {{ reason: String }}`"); + } + Ok(()) +} + +fn resolve_alias_type<'a>(name: &'a str, ctx: &BridgeCtx<'a>) -> Option<&'a TypeDef> { + let mut seen = BTreeSet::new(); + let mut current = name; + loop { + if !seen.insert(current) { + return None; + } + let type_def = *ctx.api_types.get(current)?; + match &type_def.kind { + TypeDefKind::Alias(TypeRef::Named { name, .. }) => current = name, + _ => return Some(type_def), + } + } +} + +fn named_type_name(ty: &TypeRef) -> Result<&str> { + let TypeRef::Named { name, .. } = ty else { + bail!("expected named type, got {ty:?}"); + }; + Ok(name) +} + +fn is_unit(ty: &TypeRef) -> bool { + matches!(ty, TypeRef::Unit) || matches!(ty, TypeRef::Tuple(items) if items.is_empty()) +} + +fn is_bool(ty: &TypeRef) -> bool { + matches!(ty, TypeRef::Primitive(name) if name == "bool") +} + +fn is_string(ty: &TypeRef) -> bool { + matches!(ty, TypeRef::Primitive(name) if name == "String" || name == "str") + || matches!(ty, TypeRef::Named { name, args } if name == "String" && args.is_empty()) +} + +fn is_bytes(ty: &TypeRef) -> bool { + matches!(ty, TypeRef::Vec(inner) if is_u8(inner)) + || matches!(ty, TypeRef::Array(inner, _) if is_u8(inner)) +} + +fn is_optional_bytes(ty: &TypeRef) -> bool { + matches!(ty, TypeRef::Option(inner) if is_bytes(inner)) +} + +fn is_u8(ty: &TypeRef) -> bool { + matches!(ty, TypeRef::Primitive(name) if name == "u8") +} + +fn indent_body(body: &str, spaces: usize) -> String { + let indent = " ".repeat(spaces); + body.lines() + .map(|line| { + if line.is_empty() { + String::new() + } else { + format!("{indent}{line}") + } + }) + .collect::>() + .join("\n") +} + +fn collect_local_bridge_payload_types(definition: &PlatformDefinition) -> BTreeSet<&str> { + let local: BTreeSet<&str> = definition.types.iter().map(|ty| ty.name.as_str()).collect(); + let mut out = BTreeSet::new(); + for trait_def in &definition.traits { + for method in &trait_def.methods { + for param in &method.params { + collect_local_from_type(¶m.type_ref, &local, &mut out); + } + match &method.return_shape.inner { + PlatformInner::Result { ok, .. } | PlatformInner::Plain(ok) => { + collect_local_from_type(ok, &local, &mut out); + } + PlatformInner::Stream(item) => { + collect_local_from_type(stream_item(item), &local, &mut out) + } + PlatformInner::Unit | PlatformInner::TraitObject(_) => {} + } + } + } + let mut changed = true; + while changed { + changed = false; + let referenced = definition + .types + .iter() + .filter(|ty| out.contains(ty.name.as_str())) + .collect::>(); + for type_def in referenced { + let before = out.len(); + collect_local_from_type_def(type_def, &local, &mut out); + changed |= out.len() != before; + } + } + out +} + +fn collect_local_from_type_def<'a>( + type_def: &'a TypeDef, + local: &BTreeSet<&'a str>, + out: &mut BTreeSet<&'a str>, +) { + match &type_def.kind { + TypeDefKind::Alias(type_ref) => collect_local_from_type(type_ref, local, out), + TypeDefKind::Struct(fields) => { + for field in fields { + collect_local_from_type(&field.type_ref, local, out); + } + } + TypeDefKind::TupleStruct(fields) => { + for field in fields { + collect_local_from_type(field, local, out); + } + } + TypeDefKind::Enum(variants) => { + for variant in variants { + match &variant.fields { + VariantFields::Unit => {} + VariantFields::Unnamed(types) => { + for ty in types { + collect_local_from_type(ty, local, out); + } + } + VariantFields::Named(fields) => { + for field in fields { + collect_local_from_type(&field.type_ref, local, out); + } + } + } + } + } + } +} + +fn collect_local_from_type<'a>( + ty: &'a TypeRef, + local: &BTreeSet<&'a str>, + out: &mut BTreeSet<&'a str>, +) { + match ty { + TypeRef::Named { name, args } => { + if local.contains(name.as_str()) { + out.insert(name); + } + for arg in args { + collect_local_from_type(arg, local, out); + } + } + TypeRef::Vec(inner) | TypeRef::Option(inner) | TypeRef::Array(inner, _) => { + collect_local_from_type(inner, local, out); + } + TypeRef::Tuple(items) => { + for item in items { + collect_local_from_type(item, local, out); + } + } + TypeRef::Primitive(_) | TypeRef::Generic(_) | TypeRef::Unit => {} + } +} diff --git a/rust/crates/truapi-codegen/src/rustdoc.rs b/rust/crates/truapi-codegen/src/rustdoc.rs index 8901fc0a..10dac17c 100644 --- a/rust/crates/truapi-codegen/src/rustdoc.rs +++ b/rust/crates/truapi-codegen/src/rustdoc.rs @@ -414,6 +414,8 @@ fn should_skip_type_name(name: &str) -> bool { "Subscription" | "CallContext" | "CallError" + | "CancellationFuture" + | "CancellationReason" | "CancellationToken" | "FrameworkOnlyError" | "Infallible" diff --git a/rust/crates/truapi-codegen/src/ts/host_callbacks.rs b/rust/crates/truapi-codegen/src/ts/host_callbacks.rs index 4d3f1882..ac0f52b6 100644 --- a/rust/crates/truapi-codegen/src/ts/host_callbacks.rs +++ b/rust/crates/truapi-codegen/src/ts/host_callbacks.rs @@ -18,6 +18,11 @@ use indoc::{formatdoc, writedoc}; use crate::platform::{ PlatformDefinition, PlatformInner, PlatformMethod, PlatformParam, PlatformReturn, PlatformTrait, }; +use crate::platform_callbacks::{ + callback_namespace, composed_traits, platform_trait_names, raw_callback_adapter_name, + raw_callback_name, raw_callback_type_name, raw_callback_wire_name, stream_item, to_camel_case, + trait_object_return_name, +}; use crate::rustdoc::{FieldDef, TypeDef, TypeDefKind, TypeRef, VariantDef, VariantFields}; use crate::ts::ts_string_literal; @@ -34,7 +39,7 @@ pub fn generate( ) -> Result<()> { fs::create_dir_all(callbacks_output_dir)?; fs::create_dir_all(adapter_output_dir)?; - let local_codec_types = collect_local_async_payload_types(definition); + let local_codec_types = collect_local_bridge_payload_types(definition); let body = emit_host_callbacks(definition, codec_types, &local_codec_types)?; fs::write( Path::new(callbacks_output_dir).join("host-callbacks.ts"), @@ -152,25 +157,26 @@ fn emit_host_callbacks( None, ), }; - out.push_str(&emit_super_interface("HostCallbacks", &composes, docs)); + out.push_str(&emit_host_callback_composites(&composes, docs)); Ok(out) } /// Emit the `createWasmRawCallbacks` adapter that maps the typed /// `HostCallbacks` surface onto the byte-oriented surface the WASM core -/// invokes. Named wire types cross as SCALE bytes (`.enc`/`.dec`); strings, -/// primitives, byte blobs and platform-local types (`AuthState`) pass through -/// unchanged. `ChainProvider` is delegated to the hand-written -/// `chainConnectAdapter` since its connection handle is bespoke. +/// invokes. Codec-backed wire and platform-local types cross as SCALE bytes +/// (`.enc`/`.dec`); strings, primitives and byte blobs pass through unchanged. +/// Trait-object returns are delegated to hand-written adapters whose names are +/// derived from the raw callback name. fn emit_wasm_adapter( definition: &PlatformDefinition, codec_types: &BTreeSet, local_codec_types: &BTreeSet, ) -> Result { // Only the capability traits the `Platform` super-trait composes are host - // callbacks; returned handles like `JsonRpcConnection` are not. + // callbacks; returned handle traits are adapted separately. let traits = composed_traits(definition); + let trait_names = platform_trait_names(definition); // Local types are emitted in `host-callbacks.ts` (e.g. `AuthState`); codec // types carry SCALE codecs and are imported as values for `.enc`/`.dec`. @@ -181,6 +187,8 @@ fn emit_wasm_adapter( let mut imports: BTreeSet = BTreeSet::new(); let mut extra_types: BTreeSet = BTreeSet::new(); let mut adapter_local_codec_types: BTreeSet = BTreeSet::new(); + let mut runtime_types: BTreeSet = BTreeSet::new(); + let mut support_imports: BTreeSet = BTreeSet::new(); for trait_def in &traits { for method in &trait_def.methods { for param in &method.params { @@ -192,15 +200,32 @@ fn emit_wasm_adapter( ); collect_extra_named(¶m.type_ref, codec_types, &local, &mut extra_types); } + if trait_object_return_name(method, &trait_names).is_some() { + runtime_types.insert(raw_callback_type_name(trait_def, method, &trait_names)); + support_imports.insert(raw_callback_adapter_name(trait_def, method, &trait_names)); + continue; + } match &method.return_shape.inner { PlatformInner::Result { ok, .. } | PlatformInner::Plain(ok) => { collect_codec_imports(ok, codec_types, &mut imports); + collect_local_codec_names( + ok, + local_codec_types, + &mut adapter_local_codec_types, + ); collect_extra_named(ok, codec_types, &local, &mut extra_types); } PlatformInner::Stream(item) => { - collect_codec_imports(stream_item(item), codec_types, &mut imports) + support_imports.insert("driveResultStream".to_string()); + collect_codec_imports(stream_item(item), codec_types, &mut imports); + collect_local_codec_names( + stream_item(item), + local_codec_types, + &mut adapter_local_codec_types, + ); } - PlatformInner::Unit | PlatformInner::TraitObject(_) => {} + PlatformInner::TraitObject(_) => {} + PlatformInner::Unit => {} } } } @@ -212,9 +237,9 @@ fn emit_wasm_adapter( // Auto-generated by truapi-codegen. Do not edit. // // Adapts the typed `HostCallbacks` surface onto the byte-oriented - // callback surface the WASM core invokes. Named wire types cross as - // SCALE bytes (`.enc`/`.dec`); strings, primitives, byte blobs and - // platform-local types pass through unchanged. + // callback surface the WASM core invokes. Codec-backed wire and + // platform-local types cross as SCALE bytes (`.enc`/`.dec`); strings, + // primitives and byte blobs pass through unchanged. "#, ) @@ -230,40 +255,53 @@ fn emit_wasm_adapter( writedoc!( out, r#" - import type {{ AuthState, HostCallbacks }} from "./host-callbacks.js"; - import type {{ ChainConnect }} from "../runtime.js"; - import {{ - chainConnectAdapter, - driveResultStream, - }} from "../adapter-support.js"; + import type {{ + FlatHostCallbacks, + RequiredHostCallbacks, + }} from "./host-callbacks.js"; "#, ) .unwrap(); - out.push_str(&emit_raw_callbacks(&traits, codec_types, local_codec_types)); + emit_import_block(&mut out, true, "../runtime.js", &runtime_types); + emit_import_block(&mut out, false, "../adapter-support.js", &support_imports); + if !runtime_types.is_empty() || !support_imports.is_empty() { + out.push('\n'); + } + out.push_str(&emit_raw_callbacks( + &traits, + codec_types, + local_codec_types, + &trait_names, + )); writedoc!( out, r#" /** Adapt typed host callbacks into the raw SCALE callback surface the * WASM core invokes. */ export function createWasmRawCallbacks( - host: Required, + host: RequiredHostCallbacks | Required, ): RawCallbacks {{ + const callbacks = normalizeHostCallbacks(host); return {{ "#, ) .unwrap(); for trait_def in &traits { - if trait_def.name == "ChainProvider" { - writeln!(out, " chainConnect: chainConnectAdapter(host),").unwrap(); - continue; - } for method in &trait_def.methods { - let entry = emit_adapter_entry(method, codec_types, local_codec_types)?; + let entry = emit_adapter_entry( + trait_def, + method, + codec_types, + local_codec_types, + &trait_names, + )?; writeln!(out, " {entry}").unwrap(); } } out.push_str(" };\n}\n"); + out.push('\n'); + out.push_str(&emit_normalize_host_callbacks(&traits)); Ok(out) } @@ -279,16 +317,24 @@ fn emit_worker_callbacks( _local_codec_types: &BTreeSet, ) -> Result { let traits = composed_traits(definition); + let trait_names = platform_trait_names(definition); let mut callbacks = Vec::new(); let mut subscriptions = Vec::new(); + let mut trait_object_callbacks = Vec::new(); + let mut runtime_types = BTreeSet::new(); for trait_def in traits { - if trait_def.name == "ChainProvider" { - continue; - } for method in &trait_def.methods { + if trait_object_return_name(method, &trait_names).is_some() { + runtime_types.insert(raw_callback_type_name(trait_def, method, &trait_names)); + trait_object_callbacks.push((trait_def, method)); + continue; + } match &method.return_shape.inner { PlatformInner::Stream(_) => subscriptions.push(method), + PlatformInner::TraitObject(_) => { + unreachable!("trait-object callbacks are handled above") + } _ => callbacks.push(method), } } @@ -305,12 +351,15 @@ fn emit_worker_callbacks( // file owns the callback names, host-hook arity, and // subscription payload shape derived from `truapi-platform`. - import type {{ ChainConnect }} from "../runtime.js"; import type {{ RawCallbacks }} from "./host-callbacks-adapter.js"; "# ) .unwrap(); + emit_import_block(&mut out, true, "../runtime.js", &runtime_types); + if !runtime_types.is_empty() { + out.push('\n'); + } out.push_str(&const_name_array("CALLBACK_NAMES", &callbacks)); out.push_str("export type CallbackName = typeof CALLBACK_NAMES[number];\n\n"); @@ -327,7 +376,21 @@ fn emit_worker_callbacks( payload: Uint8Array | null, sendItem: (value: T) => void, ): () => void; - chainConnect: ChainConnect; + "# + ) + .unwrap(); + for (trait_def, method) in &trait_object_callbacks { + writeln!( + out, + " {}: {};", + raw_callback_wire_name(trait_def, method, &trait_names), + raw_callback_type_name(trait_def, method, &trait_names) + ) + .unwrap(); + } + writedoc!( + out, + r#" }} "# @@ -352,7 +415,16 @@ fn emit_worker_callbacks( const callbacks: Record = {{ ...rawCallbacks(bridge), ...subscriptionRawCallbacks(bridge), - chainConnect: bridge.chainConnect, + "# + ) + .unwrap(); + for (trait_def, method) in &trait_object_callbacks { + let raw = raw_callback_wire_name(trait_def, method, &trait_names); + writeln!(out, " {raw}: bridge.{raw},").unwrap(); + } + writedoc!( + out, + r#" }}; return callbacks; }} @@ -378,18 +450,6 @@ fn emit_worker_callbacks( Ok(out) } -fn composed_traits(definition: &PlatformDefinition) -> Vec<&PlatformTrait> { - let composed: BTreeSet = match &definition.super_trait { - Some(s) => s.composes.iter().cloned().collect(), - None => definition.traits.iter().map(|t| t.name.clone()).collect(), - }; - definition - .traits - .iter() - .filter(|t| composed.contains(&t.name)) - .collect() -} - /// All names defined locally in the platform crate: capability trait names plus /// the struct/enum type names emitted into `host-callbacks.ts`. fn local_names(definition: &PlatformDefinition) -> BTreeSet { @@ -404,7 +464,7 @@ fn local_names(definition: &PlatformDefinition) -> BTreeSet { fn const_name_array(const_name: &str, methods: &[&PlatformMethod]) -> String { let entries = methods .iter() - .map(|method| format!(" \"{}\",", to_camel_case(&method.name))) + .map(|method| format!(" \"{}\",", raw_callback_name(method))) .collect::>() .join("\n"); format!("export const {const_name} = [\n{entries}\n] as const;\n") @@ -431,7 +491,7 @@ fn emit_worker_callback_factory( } fn emit_worker_callback_entry(method: &PlatformMethod) -> Result { - let raw = to_camel_case(&method.name); + let raw = raw_callback_name(method); let args = method .params .iter() @@ -471,7 +531,7 @@ fn emit_worker_subscription_factory(methods: &[&PlatformMethod]) -> Result Result, local_codec_types: &BTreeSet, + platform_trait_names: &BTreeSet, ) -> String { let mut out = String::new(); out.push_str("export interface RawCallbacks {\n"); for trait_def in traits { - if trait_def.name == "ChainProvider" { - continue; - } for method in &trait_def.methods { out.push_str(" "); - out.push_str(&raw_member(method, codec_types, local_codec_types)); + out.push_str(&raw_member( + trait_def, + method, + codec_types, + local_codec_types, + platform_trait_names, + )); out.push('\n'); } } - out.push_str(" chainConnect: ChainConnect;\n"); out.push_str("}\n"); out } /// One `RawCallbacks` member signature for `method`. fn raw_member( + trait_def: &PlatformTrait, method: &PlatformMethod, codec_types: &BTreeSet, local_codec_types: &BTreeSet, + platform_trait_names: &BTreeSet, ) -> String { - let name = to_camel_case(&method.name); + let name = raw_callback_wire_name(trait_def, method, platform_trait_names); match &method.return_shape.inner { PlatformInner::Stream(_) => { let mut params: Vec = method @@ -593,7 +658,13 @@ fn raw_member( params.push("sendItem: (item?: Uint8Array) => void".to_string()); format!("{name}({}): (() => void) | void;", params.join(", ")) } - PlatformInner::TraitObject(_) => String::new(), + _ if trait_object_return_name(method, platform_trait_names).is_some() => { + format!( + "{}: {};", + name, + raw_callback_type_name(trait_def, method, platform_trait_names) + ) + } inner => { let params = method .params @@ -609,7 +680,7 @@ fn raw_member( .join(", "); let ok = match inner { PlatformInner::Result { ok, .. } | PlatformInner::Plain(ok) => { - raw_ok_ts(ok, codec_types) + raw_ok_ts(ok, codec_types, local_codec_types) } _ => "void".to_string(), }; @@ -649,14 +720,22 @@ fn raw_param_ts( } /// TS type for a `RawCallbacks` `Result` ok value under the byte boundary. -fn raw_ok_ts(ty: &TypeRef, codec_types: &BTreeSet) -> String { +fn raw_ok_ts( + ty: &TypeRef, + codec_types: &BTreeSet, + local_codec_types: &BTreeSet, +) -> String { match ty { TypeRef::Named { name, .. } if codec_types.contains(name) => "Uint8Array".to_string(), + TypeRef::Named { name, .. } if local_codec_types.contains(name) => "Uint8Array".to_string(), TypeRef::Named { name, .. } => name.clone(), TypeRef::Vec(inner) | TypeRef::Array(inner, _) if matches!(inner.as_ref(), TypeRef::Primitive(p) if p == "u8") => { "Uint8Array".to_string() } - TypeRef::Option(inner) => format!("{} | null | undefined", raw_ok_ts(inner, codec_types)), + TypeRef::Option(inner) => format!( + "{} | null | undefined", + raw_ok_ts(inner, codec_types, local_codec_types) + ), TypeRef::Primitive(p) => raw_primitive_ts(p), TypeRef::Unit => "void".to_string(), TypeRef::Tuple(items) if items.is_empty() => "void".to_string(), @@ -716,19 +795,6 @@ fn collect_codec_imports(ty: &TypeRef, codec_types: &BTreeSet, out: &mut } } -/// Unwrap a `Result` stream item to its `T`; other item types pass -/// through. Streams carry `Result`s on the Rust side but `driveResultStream` -/// already unwraps them, so the adapter encodes the inner item type. -fn stream_item(item: &TypeRef) -> &TypeRef { - if let TypeRef::Named { name, args } = item - && name == "Result" - && let Some(ok) = args.first() - { - return ok; - } - item -} - /// The call argument expression for one Rust param. Codec types arrive as /// `Uint8Array` and are decoded; `u64`-family integers arrive as JS numbers and /// are widened to `bigint`; everything else passes through. Arrow parameter @@ -767,25 +833,39 @@ fn param_names(method: &PlatformMethod) -> String { /// host provides a complete callback implementation, so missing capability /// behavior is expressed inside the host callback rather than by omitting it. fn emit_adapter_entry( + trait_def: &PlatformTrait, method: &PlatformMethod, codec_types: &BTreeSet, local_codec_types: &BTreeSet, + platform_trait_names: &BTreeSet, ) -> Result { - let raw = to_camel_case(&method.name); + let raw = raw_callback_wire_name(trait_def, method, platform_trait_names); + let host_method = format!("callbacks.{}.{}", callback_namespace(&trait_def.name), raw); + if trait_object_return_name(method, platform_trait_names).is_some() { + let adapter = raw_callback_adapter_name(trait_def, method, platform_trait_names); + return Ok(format!( + "{raw}: {adapter}(callbacks.{}),", + callback_namespace(&trait_def.name) + )); + } let impl_expr = match &method.return_shape.inner { PlatformInner::Stream(item) => { - adapter_stream_impl(&raw, method, item, codec_types, local_codec_types)? + adapter_stream_impl(&host_method, method, item, codec_types, local_codec_types)? } PlatformInner::Result { ok, .. } => { - adapter_unary_impl(&raw, method, ok, codec_types, local_codec_types)? + adapter_unary_impl(&host_method, method, ok, codec_types, local_codec_types)? } PlatformInner::Plain(ok) => { - adapter_unary_impl(&raw, method, ok, codec_types, local_codec_types)? - } - PlatformInner::Unit => { - adapter_unary_impl(&raw, method, &TypeRef::Unit, codec_types, local_codec_types)? + adapter_unary_impl(&host_method, method, ok, codec_types, local_codec_types)? } - PlatformInner::TraitObject(_) => bail!("unexpected trait-object return on `{raw}`"), + PlatformInner::Unit => adapter_unary_impl( + &host_method, + method, + &TypeRef::Unit, + codec_types, + local_codec_types, + )?, + PlatformInner::TraitObject(_) => unreachable!("trait-object callbacks are handled above"), }; Ok(format!("{raw}: {impl_expr},")) } @@ -793,7 +873,7 @@ fn emit_adapter_entry( /// The adapter implementation expression for a unary callback: decode codec /// params, call the typed host method, SCALE-encode a codec result. fn adapter_unary_impl( - raw: &str, + host_method: &str, method: &PlatformMethod, ok: &TypeRef, codec_types: &BTreeSet, @@ -806,9 +886,11 @@ fn adapter_unary_impl( .map(|p| adapter_arg(p, codec_types, local_codec_types)) .collect::>() .join(", "); - let call = format!("host.{raw}({args})"); + let call = format!("{host_method}({args})"); let body = match ok { - TypeRef::Named { name: ty, .. } if codec_types.contains(ty) => { + TypeRef::Named { name: ty, .. } + if codec_types.contains(ty) || local_codec_types.contains(ty) => + { format!("{ty}.enc(await {call})") } _ => format!("await {call}"), @@ -819,7 +901,7 @@ fn adapter_unary_impl( /// The adapter implementation expression for a subscription callback: drive /// the host's stream into `sendItem`, SCALE-encoding each codec item. fn adapter_stream_impl( - raw: &str, + host_method: &str, method: &PlatformMethod, item: &TypeRef, codec_types: &BTreeSet, @@ -838,7 +920,9 @@ fn adapter_stream_impl( // Tick subscription: the item carries no value, so ignore it and emit // a bare tick to the core's sink. _ if is_unit => "() => sendItem()".to_string(), - TypeRef::Named { name: ty, .. } if codec_types.contains(ty) => { + TypeRef::Named { name: ty, .. } + if codec_types.contains(ty) || local_codec_types.contains(ty) => + { format!("(item) => sendItem({ty}.enc(item))") } _ => "sendItem".to_string(), @@ -850,23 +934,29 @@ fn adapter_stream_impl( .collect(); names.push("sendItem".to_string()); let params = names.join(", "); - let call = format!("host.{raw}({args})"); + let call = format!("{host_method}({args})"); Ok(format!( "({params}) => driveResultStream({call}, {item_expr})" )) } -fn collect_local_async_payload_types(definition: &PlatformDefinition) -> BTreeSet { +fn collect_local_bridge_payload_types(definition: &PlatformDefinition) -> BTreeSet { let local: BTreeSet = definition.types.iter().map(|ty| ty.name.clone()).collect(); let mut out = BTreeSet::new(); for trait_def in &definition.traits { for method in &trait_def.methods { - if !method.return_shape.is_async { - continue; - } for param in &method.params { collect_local_from_type(¶m.type_ref, &local, &mut out); } + match &method.return_shape.inner { + PlatformInner::Result { ok, .. } | PlatformInner::Plain(ok) => { + collect_local_from_type(ok, &local, &mut out); + } + PlatformInner::Stream(item) => { + collect_local_from_type(stream_item(item), &local, &mut out) + } + PlatformInner::Unit | PlatformInner::TraitObject(_) => {} + } } } let mut changed = true; @@ -1307,17 +1397,77 @@ fn inline_object_type(fields: &[FieldDef]) -> Result { Ok(format!("{{ {body} }}")) } -fn emit_super_interface(name: &str, composes: &[String], docs: Option<&str>) -> String { +fn emit_host_callback_composites(composes: &[String], docs: Option<&str>) -> String { let jsdoc = render_jsdoc("", docs); if composes.is_empty() { - return format!("{jsdoc}export interface {name} {{}}\n"); + return format!( + "{jsdoc}export interface HostCallbacks {{}}\n\nexport interface RequiredHostCallbacks {{}}\n" + ); } let extends = composes .iter() .map(|name| name.to_string()) .collect::>() .join(", "); - format!("{jsdoc}export interface {name} extends {extends} {{}}\n") + let host_members = composes + .iter() + .map(|trait_name| format!(" {}: {};", callback_namespace(trait_name), trait_name)) + .collect::>() + .join("\n"); + let required_members = composes + .iter() + .map(|trait_name| { + format!( + " {}: Required<{}>;", + callback_namespace(trait_name), + trait_name + ) + }) + .collect::>() + .join("\n"); + formatdoc! { + r#" + /** @deprecated Use the namespaced `HostCallbacks` shape instead. */ + export interface FlatHostCallbacks extends {extends} {{}} + + {jsdoc}export interface HostCallbacks {{ + {host_members} + }} + + export interface RequiredHostCallbacks {{ + {required_members} + }} + "#, + } +} + +fn emit_normalize_host_callbacks(traits: &[&PlatformTrait]) -> String { + let namespace_guard = traits + .first() + .map(|trait_def| callback_namespace(&trait_def.name)) + .unwrap_or_default(); + let members = traits + .iter() + .map(|trait_def| { + let namespace = callback_namespace(&trait_def.name); + format!(" {namespace}: host,") + }) + .collect::>() + .join("\n"); + formatdoc! { + r#" + function normalizeHostCallbacks( + host: RequiredHostCallbacks | Required, + ): RequiredHostCallbacks {{ + if ("{namespace_guard}" in host) {{ + return host; + }} + return {{ + {members} + }}; + }} + "#, + } } fn collect_named_types(definition: &PlatformDefinition) -> BTreeSet { @@ -1463,21 +1613,3 @@ fn render_ts_doc_line(line: &str) -> String { .replace("Ok(())", "success") .replace("None", "`undefined`") } - -fn to_camel_case(name: &str) -> String { - let mut out = String::with_capacity(name.len()); - let mut upper_next = false; - for (idx, ch) in name.chars().enumerate() { - if ch == '_' { - upper_next = idx != 0; - continue; - } - if upper_next { - out.extend(ch.to_uppercase()); - upper_next = false; - } else { - out.push(ch); - } - } - out -} diff --git a/rust/crates/truapi-codegen/tests/golden/host-callbacks-adapter.ts b/rust/crates/truapi-codegen/tests/golden/host-callbacks-adapter.ts index 3edef0ec..f56af427 100644 --- a/rust/crates/truapi-codegen/tests/golden/host-callbacks-adapter.ts +++ b/rust/crates/truapi-codegen/tests/golden/host-callbacks-adapter.ts @@ -1,9 +1,9 @@ // Auto-generated by truapi-codegen. Do not edit. // // Adapts the typed `HostCallbacks` surface onto the byte-oriented -// callback surface the WASM core invokes. Named wire types cross as -// SCALE bytes (`.enc`/`.dec`); strings, primitives, byte blobs and -// platform-local types pass through unchanged. +// callback surface the WASM core invokes. Codec-backed wire and +// platform-local types cross as SCALE bytes (`.enc`/`.dec`); strings, +// primitives and byte blobs pass through unchanged. import { HostDevicePermissionRequest, @@ -20,18 +20,26 @@ import type { NotificationId, } from "@parity/truapi"; import { + AuthState, CoreStorageKey, UserConfirmationReview, } from "./host-callbacks.js"; -import type { AuthState, HostCallbacks } from "./host-callbacks.js"; -import type { ChainConnect } from "../runtime.js"; +import type { + FlatHostCallbacks, + RequiredHostCallbacks, +} from "./host-callbacks.js"; + +import type { + ChainConnect, +} from "../runtime.js"; import { chainConnectAdapter, driveResultStream, } from "../adapter-support.js"; export interface RawCallbacks { - authStateChanged(state: AuthState): void; + authStateChanged(state: Uint8Array): void; + chainConnect: ChainConnect; readCoreStorage(key: Uint8Array): Promise; writeCoreStorage(key: Uint8Array, value: Uint8Array): Promise; clearCoreStorage(key: Uint8Array): Promise; @@ -48,31 +56,52 @@ export interface RawCallbacks { clear(key: string): Promise; subscribeTheme(sendItem: (item?: Uint8Array) => void): (() => void) | void; confirmUserAction(review: Uint8Array): Promise; - chainConnect: ChainConnect; } /** Adapt typed host callbacks into the raw SCALE callback surface the * WASM core invokes. */ export function createWasmRawCallbacks( - host: Required, + host: RequiredHostCallbacks | Required, ): RawCallbacks { + const callbacks = normalizeHostCallbacks(host); + return { + authStateChanged: async (state) => await callbacks.auth.authStateChanged(AuthState.dec(state)), + chainConnect: chainConnectAdapter(callbacks.chain), + readCoreStorage: async (key) => await callbacks.coreStorage.readCoreStorage(CoreStorageKey.dec(key)), + writeCoreStorage: async (key, value) => await callbacks.coreStorage.writeCoreStorage(CoreStorageKey.dec(key), value), + clearCoreStorage: async (key) => await callbacks.coreStorage.clearCoreStorage(CoreStorageKey.dec(key)), + featureSupported: async (request) => HostFeatureSupportedResponse.enc(await callbacks.features.featureSupported(HostFeatureSupportedRequest.dec(request))), + navigateTo: async (url) => await callbacks.navigation.navigateTo(url), + pushNotification: async (notification) => HostPushNotificationResponse.enc(await callbacks.notifications.pushNotification(HostPushNotificationRequest.dec(notification))), + cancelNotification: async (id) => await callbacks.notifications.cancelNotification(id), + devicePermission: async (request) => HostDevicePermissionResponse.enc(await callbacks.permissions.devicePermission(HostDevicePermissionRequest.dec(request))), + remotePermission: async (request) => RemotePermissionResponse.enc(await callbacks.permissions.remotePermission(RemotePermissionRequest.dec(request))), + submitPreimage: async (value) => await callbacks.preimage.submitPreimage(value), + lookupPreimage: (key, sendItem) => driveResultStream(callbacks.preimage.lookupPreimage(key), sendItem), + read: async (key) => await callbacks.productStorage.read(key), + write: async (key, value) => await callbacks.productStorage.write(key, value), + clear: async (key) => await callbacks.productStorage.clear(key), + subscribeTheme: (sendItem) => driveResultStream(callbacks.theme.subscribeTheme(), (item) => sendItem(ThemeVariant.enc(item))), + confirmUserAction: async (review) => await callbacks.userConfirmation.confirmUserAction(UserConfirmationReview.dec(review)), + }; +} + +function normalizeHostCallbacks( + host: RequiredHostCallbacks | Required, +): RequiredHostCallbacks { + if ("auth" in host) { + return host; + } return { - authStateChanged: async (state) => await host.authStateChanged(state), - chainConnect: chainConnectAdapter(host), - readCoreStorage: async (key) => await host.readCoreStorage(CoreStorageKey.dec(key)), - writeCoreStorage: async (key, value) => await host.writeCoreStorage(CoreStorageKey.dec(key), value), - clearCoreStorage: async (key) => await host.clearCoreStorage(CoreStorageKey.dec(key)), - featureSupported: async (request) => HostFeatureSupportedResponse.enc(await host.featureSupported(HostFeatureSupportedRequest.dec(request))), - navigateTo: async (url) => await host.navigateTo(url), - pushNotification: async (notification) => HostPushNotificationResponse.enc(await host.pushNotification(HostPushNotificationRequest.dec(notification))), - cancelNotification: async (id) => await host.cancelNotification(id), - devicePermission: async (request) => HostDevicePermissionResponse.enc(await host.devicePermission(HostDevicePermissionRequest.dec(request))), - remotePermission: async (request) => RemotePermissionResponse.enc(await host.remotePermission(RemotePermissionRequest.dec(request))), - submitPreimage: async (value) => await host.submitPreimage(value), - lookupPreimage: (key, sendItem) => driveResultStream(host.lookupPreimage(key), sendItem), - read: async (key) => await host.read(key), - write: async (key, value) => await host.write(key, value), - clear: async (key) => await host.clear(key), - subscribeTheme: (sendItem) => driveResultStream(host.subscribeTheme(), (item) => sendItem(ThemeVariant.enc(item))), - confirmUserAction: async (review) => await host.confirmUserAction(UserConfirmationReview.dec(review)), + auth: host, + chain: host, + coreStorage: host, + features: host, + navigation: host, + notifications: host, + permissions: host, + preimage: host, + productStorage: host, + theme: host, + userConfirmation: host, }; } diff --git a/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts b/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts index 407e2050..4c060456 100644 --- a/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts +++ b/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts @@ -74,6 +74,9 @@ export type AuthState = /** * Core-owned host-private storage slots. Products never address these slots; * the host chooses the backing store for each slot. + * + * Storage is host-local; `storage.md` records the current status quo: + * */ export type CoreStorageKey = /** @@ -102,6 +105,16 @@ export type CreateTransactionReview = */ | { tag: "LegacyAccount"; value: LegacyAccountTxPayload }; +/** + * Review shown before a product learns the user's primary identity. + */ +export interface IdentityDisclosureReview { + /** + * Product currently handling the request. + */ + productId: string; +} + /** * Permission request whose authorization status can be inspected or updated * by host administration UI. @@ -114,7 +127,11 @@ export type PermissionAuthorizationRequest = /** * Remote/product-scoped permission such as chain submit or HTTP access. */ - | { tag: "Remote"; value: RemotePermissionRequest }; + | { tag: "Remote"; value: RemotePermissionRequest } + /** + * Product-scoped permission to disclose the user's primary identity. + */ + | { tag: "IdentityDisclosure"; value?: undefined }; /** * Authorization status for a permission request. @@ -206,6 +223,10 @@ export type UserConfirmationReview = * Allow a product to request another product account alias. */ | { tag: "AccountAlias"; value: AccountAliasReview } + /** + * Allow a product to learn the user's primary identity. + */ + | { tag: "IdentityDisclosure"; value: IdentityDisclosureReview } /** * Allocate resources for the requesting product. */ @@ -220,9 +241,19 @@ export type UserConfirmationReview = */ export const AccountAliasReview: S.Codec = S.lazy((): S.Codec => S.Struct({requestingProductId: S.str, targetProductId: S.str}) as S.Codec); +/** + * Auth/session lifecycle state the core projects for host UI. The core owns + * every transition and emits states in order; hosts render the current state + * and never derive auth UI from any other signal. + */ +export const AuthState: S.Codec = S.lazy((): S.Codec => S.TaggedUnion({Disconnected: S._void, Pairing: S.Struct({deeplink: S.str}) as S.Codec<{ deeplink: string }>, Connected: SessionUiInfo, LoginFailed: S.Struct({reason: S.str}) as S.Codec<{ reason: string }>})); + /** * Core-owned host-private storage slots. Products never address these slots; * the host chooses the backing store for each slot. + * + * Storage is host-local; `storage.md` records the current status quo: + * */ export const CoreStorageKey: S.Codec = S.lazy((): S.Codec => S.TaggedUnion({AuthSession: S._void, PairingDeviceIdentity: S._void, PermissionAuthorization: S.Struct({productId: S.str, request: PermissionAuthorizationRequest}) as S.Codec<{ productId: string; request: PermissionAuthorizationRequest }>})); @@ -231,11 +262,16 @@ export const CoreStorageKey: S.Codec = S.lazy((): S.Codec = S.lazy((): S.Codec => S.TaggedUnion({Product: ProductAccountTxPayload, LegacyAccount: LegacyAccountTxPayload})); +/** + * Review shown before a product learns the user's primary identity. + */ +export const IdentityDisclosureReview: S.Codec = S.lazy((): S.Codec => S.Struct({productId: S.str}) as S.Codec); + /** * Permission request whose authorization status can be inspected or updated * by host administration UI. */ -export const PermissionAuthorizationRequest: S.Codec = S.lazy((): S.Codec => S.TaggedUnion({Device: HostDevicePermissionRequest, Remote: RemotePermissionRequest})); +export const PermissionAuthorizationRequest: S.Codec = S.lazy((): S.Codec => S.TaggedUnion({Device: HostDevicePermissionRequest, Remote: RemotePermissionRequest, IdentityDisclosure: S._void})); /** * Authorization status for a permission request. @@ -250,6 +286,12 @@ export const PermissionAuthorizationStatus: S.Codec = S.lazy((): S.Codec => S.Struct({size: S.u64}) as S.Codec); +/** + * Decoded session fields a host shell needs to render account UI without + * parsing the opaque session blob the core persists through `CoreStorage`. + */ +export const SessionUiInfo: S.Codec = S.lazy((): S.Codec => S.Struct({publicKey: S.Bytes(32), identityAccountId: S.Option(S.Bytes(32)), liteUsername: S.Option(S.str), fullUsername: S.Option(S.str)}) as S.Codec); + /** * Review shown before a sign-payload request is sent to the paired wallet. */ @@ -263,7 +305,7 @@ export const SignRawReview: S.Codec = S.lazy((): S.Codec = S.lazy((): S.Codec => S.TaggedUnion({SignPayload: SignPayloadReview, SignRaw: SignRawReview, CreateTransaction: CreateTransactionReview, AccountAlias: AccountAliasReview, ResourceAllocation: HostRequestResourceAllocationRequest, PreimageSubmit: PreimageSubmitReview})); +export const UserConfirmationReview: S.Codec = S.lazy((): S.Codec => S.TaggedUnion({SignPayload: SignPayloadReview, SignRaw: SignRawReview, CreateTransaction: CreateTransactionReview, AccountAlias: AccountAliasReview, IdentityDisclosure: IdentityDisclosureReview, ResourceAllocation: HostRequestResourceAllocationRequest, PreimageSubmit: PreimageSubmitReview})); /** * Host auth UI driven by core-owned `AuthState` transitions. @@ -282,6 +324,9 @@ export interface AuthPresenter { * * The platform provides a way to get a JSON-RPC connection for a given chain. * The server runtime manages the chainHead v1 state machine on top of this. + * Host-spec N.6 requires products to access chains through host-mediated + * providers: + * */ export interface ChainProvider { /** @@ -491,16 +536,45 @@ export interface ThemeHost { } /** - * Local user confirmation UI for session-channel operations. + * Local user confirmation UI for sensitive core-owned operations. */ export interface UserConfirmation { /** - * Confirm a reviewed action before the core asks the SSO peer. + * Confirm a reviewed action before the core continues. */ confirmUserAction(review: UserConfirmationReview): Promise; } +/** @deprecated Use the namespaced `HostCallbacks` shape instead. */ +export interface FlatHostCallbacks extends Navigation, Notifications, Permissions, Features, ProductStorage, CoreStorage, ChainProvider, AuthPresenter, UserConfirmation, ThemeHost, PreimageHost {} + /** * Combined platform interface. A host must provide all capability traits. */ -export interface HostCallbacks extends Navigation, Notifications, Permissions, Features, ProductStorage, CoreStorage, ChainProvider, AuthPresenter, UserConfirmation, ThemeHost, PreimageHost {} +export interface HostCallbacks { + navigation: Navigation; + notifications: Notifications; + permissions: Permissions; + features: Features; + productStorage: ProductStorage; + coreStorage: CoreStorage; + chain: ChainProvider; + auth: AuthPresenter; + userConfirmation: UserConfirmation; + theme: ThemeHost; + preimage: PreimageHost; +} + +export interface RequiredHostCallbacks { + navigation: Required; + notifications: Required; + permissions: Required; + features: Required; + productStorage: Required; + coreStorage: Required; + chain: Required; + auth: Required; + userConfirmation: Required; + theme: Required; + preimage: Required; +} diff --git a/rust/crates/truapi-codegen/tests/golden/wasm_bridge.rs b/rust/crates/truapi-codegen/tests/golden/wasm_bridge.rs new file mode 100644 index 00000000..50f2614b --- /dev/null +++ b/rust/crates/truapi-codegen/tests/golden/wasm_bridge.rs @@ -0,0 +1,300 @@ +//! Auto-generated by truapi-codegen. Do not edit. +//! +//! Mechanical wasm-bindgen callback bridge derived from +//! `truapi-platform`. Raw callback names and payload shapes match the +//! generated TypeScript host-callback adapter. + +use futures::stream::BoxStream; +use js_sys::{Function, Uint8Array}; +use parity_scale_codec::Encode; +use truapi::v01; +use wasm_bindgen::JsValue; + +use super::{ + WasmPlatform, call_js_function, decode_bytes, decode_js_item, generic, get_function, + invoke_bool, invoke_bytes_return, invoke_js_subscription, invoke_optional_bytes_return, + invoke_unit, parse_optional_bytes_item, +}; + +/// JS-side callbacks invoked by the wasm platform bridge. Methods with +/// Rust default bodies are still required here because the generated TS +/// adapter resolves optional host callbacks before constructing this +/// raw callback object. +pub(super) struct JsBridge { + pub(super) auth_state_changed: Function, + pub(super) chain_connect: Function, + pub(super) read_core_storage: Function, + pub(super) write_core_storage: Function, + pub(super) clear_core_storage: Function, + pub(super) feature_supported: Function, + pub(super) navigate_to: Function, + pub(super) push_notification: Function, + pub(super) cancel_notification: Function, + pub(super) device_permission: Function, + pub(super) remote_permission: Function, + pub(super) submit_preimage: Function, + pub(super) lookup_preimage: Function, + pub(super) read: Function, + pub(super) write: Function, + pub(super) clear: Function, + pub(super) subscribe_theme: Function, + pub(super) confirm_user_action: Function, +} + +impl JsBridge { + pub(super) fn from_js(callbacks: &JsValue) -> Result { + Ok(Self { + auth_state_changed: get_function(callbacks, "authStateChanged")?, + chain_connect: get_function(callbacks, "chainConnect")?, + read_core_storage: get_function(callbacks, "readCoreStorage")?, + write_core_storage: get_function(callbacks, "writeCoreStorage")?, + clear_core_storage: get_function(callbacks, "clearCoreStorage")?, + feature_supported: get_function(callbacks, "featureSupported")?, + navigate_to: get_function(callbacks, "navigateTo")?, + push_notification: get_function(callbacks, "pushNotification")?, + cancel_notification: get_function(callbacks, "cancelNotification")?, + device_permission: get_function(callbacks, "devicePermission")?, + remote_permission: get_function(callbacks, "remotePermission")?, + submit_preimage: get_function(callbacks, "submitPreimage")?, + lookup_preimage: get_function(callbacks, "lookupPreimage")?, + read: get_function(callbacks, "read")?, + write: get_function(callbacks, "write")?, + clear: get_function(callbacks, "clear")?, + subscribe_theme: get_function(callbacks, "subscribeTheme")?, + confirm_user_action: get_function(callbacks, "confirmUserAction")?, + }) + } +} + +impl truapi_platform::AuthPresenter for WasmPlatform { + fn auth_state_changed(&self, state: truapi_platform::AuthState) { + if let Err(reason) = call_js_function( + &self.bridge.auth_state_changed, + &vec![Uint8Array::from(state.encode().as_slice()).into()], + ) { + web_sys::console::error_1(&JsValue::from_str(&reason)); + } + } +} + +#[truapi_platform::async_trait] +impl truapi_platform::CoreStorage for WasmPlatform { + async fn read_core_storage( + &self, + key: truapi_platform::CoreStorageKey, + ) -> Result>, v01::GenericError> { + invoke_optional_bytes_return( + &self.bridge.read_core_storage, + vec![Uint8Array::from(key.encode().as_slice()).into()], + "readCoreStorage must resolve to Uint8Array, null or undefined", + ) + .await + .map_err(generic) + } + + async fn write_core_storage( + &self, + key: truapi_platform::CoreStorageKey, + value: Vec, + ) -> Result<(), v01::GenericError> { + invoke_unit( + &self.bridge.write_core_storage, + vec![ + Uint8Array::from(key.encode().as_slice()).into(), + Uint8Array::from(value.as_slice()).into(), + ], + ) + .await + .map_err(generic) + } + + async fn clear_core_storage( + &self, + key: truapi_platform::CoreStorageKey, + ) -> Result<(), v01::GenericError> { + invoke_unit( + &self.bridge.clear_core_storage, + vec![Uint8Array::from(key.encode().as_slice()).into()], + ) + .await + .map_err(generic) + } +} + +#[truapi_platform::async_trait] +impl truapi_platform::Features for WasmPlatform { + async fn feature_supported( + &self, + request: v01::HostFeatureSupportedRequest, + ) -> Result { + let bytes = invoke_bytes_return( + &self.bridge.feature_supported, + vec![Uint8Array::from(request.encode().as_slice()).into()], + ) + .await + .map_err(generic)?; + decode_bytes::( + bytes, + "featureSupported response did not decode", + ) + .map_err(generic) + } +} + +#[truapi_platform::async_trait] +impl truapi_platform::Navigation for WasmPlatform { + async fn navigate_to(&self, url: String) -> Result<(), v01::HostNavigateToError> { + invoke_unit(&self.bridge.navigate_to, vec![JsValue::from_str(&url)]) + .await + .map_err(|reason| v01::HostNavigateToError::Unknown { reason }) + } +} + +#[truapi_platform::async_trait] +impl truapi_platform::Notifications for WasmPlatform { + async fn push_notification( + &self, + notification: v01::HostPushNotificationRequest, + ) -> Result { + let bytes = invoke_bytes_return( + &self.bridge.push_notification, + vec![Uint8Array::from(notification.encode().as_slice()).into()], + ) + .await + .map_err(generic)?; + decode_bytes::( + bytes, + "pushNotification response did not decode", + ) + .map_err(generic) + } + + async fn cancel_notification(&self, id: v01::NotificationId) -> Result<(), v01::GenericError> { + invoke_unit( + &self.bridge.cancel_notification, + vec![JsValue::from_f64(f64::from(id))], + ) + .await + .map_err(generic) + } +} + +#[truapi_platform::async_trait] +impl truapi_platform::Permissions for WasmPlatform { + async fn device_permission( + &self, + request: v01::HostDevicePermissionRequest, + ) -> Result { + let bytes = invoke_bytes_return( + &self.bridge.device_permission, + vec![Uint8Array::from(request.encode().as_slice()).into()], + ) + .await + .map_err(generic)?; + decode_bytes::( + bytes, + "devicePermission response did not decode", + ) + .map_err(generic) + } + + async fn remote_permission( + &self, + request: v01::RemotePermissionRequest, + ) -> Result { + let bytes = invoke_bytes_return( + &self.bridge.remote_permission, + vec![Uint8Array::from(request.encode().as_slice()).into()], + ) + .await + .map_err(generic)?; + decode_bytes::( + bytes, + "remotePermission response did not decode", + ) + .map_err(generic) + } +} + +#[truapi_platform::async_trait] +impl truapi_platform::PreimageHost for WasmPlatform { + async fn submit_preimage(&self, value: Vec) -> Result, v01::PreimageSubmitError> { + invoke_bytes_return( + &self.bridge.submit_preimage, + vec![Uint8Array::from(value.as_slice()).into()], + ) + .await + .map_err(|reason| v01::PreimageSubmitError::Unknown { reason }) + } + + fn lookup_preimage( + &self, + key: Vec, + ) -> BoxStream<'static, Result>, v01::GenericError>> { + invoke_js_subscription( + &self.bridge.lookup_preimage, + Some(key), + parse_optional_bytes_item, + ) + } +} + +#[truapi_platform::async_trait] +impl truapi_platform::ProductStorage for WasmPlatform { + async fn read(&self, key: String) -> Result>, v01::HostLocalStorageReadError> { + invoke_optional_bytes_return( + &self.bridge.read, + vec![JsValue::from_str(&key)], + "read must resolve to Uint8Array, null or undefined", + ) + .await + .map_err(|reason| v01::HostLocalStorageReadError::Unknown { reason }) + } + + async fn write( + &self, + key: String, + value: Vec, + ) -> Result<(), v01::HostLocalStorageReadError> { + invoke_unit( + &self.bridge.write, + vec![ + JsValue::from_str(&key), + Uint8Array::from(value.as_slice()).into(), + ], + ) + .await + .map_err(|reason| v01::HostLocalStorageReadError::Unknown { reason }) + } + + async fn clear(&self, key: String) -> Result<(), v01::HostLocalStorageReadError> { + invoke_unit(&self.bridge.clear, vec![JsValue::from_str(&key)]) + .await + .map_err(|reason| v01::HostLocalStorageReadError::Unknown { reason }) + } +} + +impl truapi_platform::ThemeHost for WasmPlatform { + fn subscribe_theme(&self) -> BoxStream<'static, Result> { + invoke_js_subscription(&self.bridge.subscribe_theme, None, parse_theme_variant_item) + } +} + +#[truapi_platform::async_trait] +impl truapi_platform::UserConfirmation for WasmPlatform { + async fn confirm_user_action( + &self, + review: truapi_platform::UserConfirmationReview, + ) -> Result { + invoke_bool( + &self.bridge.confirm_user_action, + vec![Uint8Array::from(review.encode().as_slice()).into()], + ) + .await + .map_err(generic) + } +} + +fn parse_theme_variant_item(value: JsValue) -> Result { + decode_js_item::(value, "ThemeVariant") +} diff --git a/rust/crates/truapi-codegen/tests/golden/worker-callbacks.ts b/rust/crates/truapi-codegen/tests/golden/worker-callbacks.ts index 34275dbf..451b7bcc 100644 --- a/rust/crates/truapi-codegen/tests/golden/worker-callbacks.ts +++ b/rust/crates/truapi-codegen/tests/golden/worker-callbacks.ts @@ -5,9 +5,12 @@ // file owns the callback names, host-hook arity, and // subscription payload shape derived from `truapi-platform`. -import type { ChainConnect } from "../runtime.js"; import type { RawCallbacks } from "./host-callbacks-adapter.js"; +import type { + ChainConnect, +} from "../runtime.js"; + export const CALLBACK_NAMES = [ "authStateChanged", "readCoreStorage", diff --git a/rust/crates/truapi-codegen/tests/golden_rust_emit.rs b/rust/crates/truapi-codegen/tests/golden_rust_emit.rs index 98f7a095..bd650d13 100644 --- a/rust/crates/truapi-codegen/tests/golden_rust_emit.rs +++ b/rust/crates/truapi-codegen/tests/golden_rust_emit.rs @@ -31,30 +31,6 @@ fn quoted_strings_in_const_array(src: &str, const_name: &str) -> Vec { .collect() } -fn wasm_optional_callback_names(workspace: &Path) -> Vec { - let wasm_rs = workspace.join("rust/crates/truapi-server/src/wasm.rs"); - if !wasm_rs.exists() { - return Vec::new(); - } - let src = fs::read_to_string(wasm_rs).expect("read wasm.rs"); - let mut names = src - .lines() - .filter_map(|line| { - let line = line.trim(); - let start = line.find("get_optional_function(callbacks, \"")?; - let quoted = &line[start + "get_optional_function(callbacks, \"".len()..]; - let end = quoted.find('"')?; - let name = "ed[..end]; - match name { - "chainConnect" | "dispose" => None, - _ => Some(name.to_string()), - } - }) - .collect::>(); - names.sort(); - names -} - /// Run `cargo +nightly rustdoc -p truapi --output-format json` into the /// given `target_dir` and return the path to the produced JSON file. /// Panics with a clear message if nightly is unavailable so CI cannot @@ -217,6 +193,8 @@ fn golden_host_callbacks_ts() { tempdir.path().join("host").to_str().unwrap(), "--platform-wasm-adapter-output", tempdir.path().join("wasm").to_str().unwrap(), + "--platform-rust-output", + tempdir.path().join("rust-wasm").to_str().unwrap(), ]) .output() .expect("run truapi-codegen"); @@ -267,6 +245,20 @@ fn golden_host_callbacks_ts() { ); } + let wasm_bridge_golden_path = manifest_dir.join("tests/golden/wasm_bridge.rs"); + let wasm_bridge_actual = + fs::read_to_string(tempdir.path().join("rust-wasm/generated_bridge.rs")) + .expect("read generated wasm bridge"); + let wasm_bridge_golden = fs::read_to_string(&wasm_bridge_golden_path).unwrap_or_default(); + if wasm_bridge_golden != wasm_bridge_actual { + let dump = manifest_dir.join("tests/golden/wasm_bridge.rs.actual"); + let _ = fs::write(&dump, &wasm_bridge_actual); + panic!( + "golden mismatch for wasm_bridge.rs; wrote actual to {}", + dump.display() + ); + } + assert!( !worker_actual.contains("OPTIONAL_CALLBACK_NAMES"), "worker callback generation should not expose an optional callback manifest" @@ -276,11 +268,10 @@ fn golden_host_callbacks_ts() { &worker_actual, "SUBSCRIPTION_NAMES", )); - let wasm_optional = wasm_optional_callback_names(&workspace); - for name in wasm_optional { + for name in generated_names { assert!( - generated_names.contains(&name), - "generated worker names must include JsBridge optional callback `{name}`" + wasm_bridge_actual.contains(&format!("get_function(callbacks, \"{name}\")?")), + "generated wasm bridge must bind worker callback `{name}`" ); } } diff --git a/rust/crates/truapi-platform/src/lib.rs b/rust/crates/truapi-platform/src/lib.rs index 9b8ccac5..b133af52 100644 --- a/rust/crates/truapi-platform/src/lib.rs +++ b/rust/crates/truapi-platform/src/lib.rs @@ -42,10 +42,17 @@ pub struct HostRuntimeConfig { #[derive(Debug, Clone, PartialEq, Eq)] pub struct PairingHostConfig { /// Host identity shown to the signing host during pairing. + /// + /// Host-spec B.1.3 defines the host metadata consumed by the signing host: + /// pub host: HostRuntimeConfig, /// People-chain genesis hash used for statement-store SSO. pub people_chain_genesis_hash: [u8; 32], /// Deeplink URI scheme used in pairing QR payloads, without `://`. + /// + /// Host-spec L.2-L.3 define the `polkadotapp://pair` route and construction + /// rules: + /// pub pairing_deeplink_scheme: String, } @@ -70,6 +77,9 @@ pub struct SigningHostConfig { pub struct ProductContext { /// Product identifier used for account derivation and product-scoped /// storage/permission namespaces. + /// + /// Host-spec C.7 defines accepted product id forms: + /// pub product_id: String, } @@ -305,6 +315,8 @@ pub enum PermissionAuthorizationRequest { Device(HostDevicePermissionRequest), /// Remote/product-scoped permission such as chain submit or HTTP access. Remote(RemotePermissionRequest), + /// Product-scoped permission to disclose the user's primary identity. + IdentityDisclosure, } /// Authorization status for a permission request. @@ -382,6 +394,9 @@ pub trait Features: Send + Sync { /// /// The platform provides a way to get a JSON-RPC connection for a given chain. /// The server runtime manages the chainHead v1 state machine on top of this. +/// Host-spec N.6 requires products to access chains through host-mediated +/// providers: +/// #[async_trait] pub trait ChainProvider: Send + Sync { /// Open a JSON-RPC connection for the chain identified by `genesis_hash`. @@ -409,6 +424,9 @@ pub trait JsonRpcConnection: Send + Sync { /// Core-owned host-private storage slots. Products never address these slots; /// the host chooses the backing store for each slot. +/// +/// Storage is host-local; `storage.md` records the current status quo: +/// #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] pub enum CoreStorageKey { /// Opaque SSO/auth session blob. @@ -444,7 +462,7 @@ pub trait CoreStorage: Send + Sync { /// Decoded session fields a host shell needs to render account UI without /// parsing the opaque session blob the core persists through [`CoreStorage`]. -#[derive(Debug, Clone, Default, PartialEq, Eq)] +#[derive(Debug, Clone, Default, PartialEq, Eq, Encode, Decode)] pub struct SessionUiInfo { /// 32-byte sr25519 root public key of the active session. pub public_key: [u8; 32], @@ -459,7 +477,7 @@ pub struct SessionUiInfo { /// Auth/session lifecycle state the core projects for host UI. The core owns /// every transition and emits states in order; hosts render the current state /// and never derive auth UI from any other signal. -#[derive(Debug, Clone, Default, PartialEq, Eq)] +#[derive(Debug, Clone, Default, PartialEq, Eq, Encode, Decode)] pub enum AuthState { /// No active session and no login in progress. #[default] @@ -526,6 +544,13 @@ pub struct AccountAliasReview { pub target_product_id: String, } +/// Review shown before a product learns the user's primary identity. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct IdentityDisclosureReview { + /// Product currently handling the request. + pub product_id: String, +} + /// Review shown before a preimage is submitted. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] pub struct PreimageSubmitReview { @@ -545,16 +570,18 @@ pub enum UserConfirmationReview { CreateTransaction(CreateTransactionReview), /// Allow a product to request another product account alias. AccountAlias(AccountAliasReview), + /// Allow a product to learn the user's primary identity. + IdentityDisclosure(IdentityDisclosureReview), /// Allocate resources for the requesting product. ResourceAllocation(HostRequestResourceAllocationRequest), /// Submit a preimage to the host-selected backend. PreimageSubmit(PreimageSubmitReview), } -/// Local user confirmation UI for session-channel operations. +/// Local user confirmation UI for sensitive core-owned operations. #[async_trait] pub trait UserConfirmation: Send + Sync { - /// Confirm a reviewed action before the core asks the SSO peer. + /// Confirm a reviewed action before the core continues. async fn confirm_user_action( &self, review: UserConfirmationReview, diff --git a/rust/crates/truapi-server/Cargo.toml b/rust/crates/truapi-server/Cargo.toml index a4a86a0c..648fed22 100644 --- a/rust/crates/truapi-server/Cargo.toml +++ b/rust/crates/truapi-server/Cargo.toml @@ -11,6 +11,9 @@ crate-type = ["rlib", "cdylib"] [features] default = [] +[lints.rust] +unsafe_code = "forbid" + [dependencies] truapi = { path = "../truapi" } truapi-platform = { path = "../truapi-platform" } @@ -31,6 +34,7 @@ sp-crypto-hashing = { version = "0.1", default-features = false } bs58 = { version = "0.5", default-features = false, features = ["alloc"] } schnorrkel = { version = "0.11.5", default-features = false, features = ["alloc", "getrandom"] } substrate-bip39 = { version = "0.6", default-features = false } +zeroize = { version = "1", default-features = false, features = ["alloc"] } getrandom = { version = "0.2", features = ["js"] } p256 = { version = "0.13", default-features = false, features = ["ecdh"] } hkdf = "0.12" diff --git a/rust/crates/truapi-server/README.md b/rust/crates/truapi-server/README.md index c4a91856..a69dca86 100644 --- a/rust/crates/truapi-server/README.md +++ b/rust/crates/truapi-server/README.md @@ -13,6 +13,185 @@ _Runtime core for TrUAPI: dispatcher, protocol frames, SCALE-coded wire envelope - the [`Transport`] trait that platform-specific IPC backends implement - the auto-generated dispatcher/wire-table tables shipped under [`crate::generated`] +- the host embedding surface: one long-lived role handle + (`PairingHostRuntime` or `SigningHostRuntime`) per host application, exposing + shared [`RuntimeServices`] plus one [`ProductRuntime`] per product connection + +## Architecture + +Two ownership bands. A **per-product-connection** band (byte frames → +dispatcher → role-neutral product runtime) is minted once per host↔product +connection by the role handle and lives for that product's whole session; a +**shared per-host** band owns role-neutral infrastructure (`RuntimeServices`) +and the role object (`PairingHost` or `SigningHost`), which is itself the +`ProductAuthority`. Pure `host_logic` is a no-I/O library both bands call, not a +stage in the frame path; the host's `Platform` impl is the syscall floor. + +```text + ┌───────────────────────────────────────────────────────┐ + │ product sandboxed iframe · native WebView │ + └───────────────────────────────────────────────────────┘ + │ ▲ + SCALE frames │ │ MessageChannel · loopback + both directions ▼ │ WS + ┌───────────────────────────────────────────────────────┐ + │ binding layer : host shell / transport adapter │ + │ thin byte bridge · no protocol logic │ + └───────────────────────────────────────────────────────┘ + + ══ per host→product connection ( one per connected product ) ══ + ┌───────────────────────────────────────────────────────┐ + │ ProductRuntime frame endpoint │ + │ decode each SCALE frame → dispatch one typed call │ + └───────────────────────────────────────────────────────┘ + │ typed method call + ▼ + ┌───────────────────────────────────────────────────────┐ + │ ProductRuntimeHost role-neutral │ + │ validate · permission-gate · confirm │ + └───────────────────────────────────────────────────────┘ + │ wallet-authority tail : + │ sign · alias · entropy · alloc + │ via Arc + ▼ + + ══ shared per host app ( one per host, all connections ) ══════ + the PairingHostRuntime | SigningHostRuntime handle owns both: + ┌─────────────────────────────┐ ┌────────────────────────┐ + │ role = ProductAuthority │ │ RuntimeServices │ + │ PairingHost | SigningHost │ │ platform · chain · RPC │ + └─────────────────────────────┘ └────────────────────────┘ + │ + │ PairingHost only : encrypted SSO channel + ▼ + ┌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┐ + ╎ remote signing host ( external wallet ) ╎ + └╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┘ + + both bands call host_logic for pure work, never traverse it : + ┌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┐ + ╎ host_logic pure library ( no I/O ) ╎ + ╎ crypto · codecs · derivation · policy ╎ + └╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┘ + + ══ host-owned floor · where every I/O above bottoms out ═══════ + ┌───────────────────────────────────────────────────────┐ + │ Platform impl ( TS / Swift / Kotlin ) │ + │ storage · prompts · chain RPC · navigation │ + └───────────────────────────────────────────────────────┘ +``` + +`ProductRuntimeHost` handles everything role-neutral (id normalization, +permission gating, confirmation, soft product-key derivation), then delegates +the wallet-authority tail (`sign_*`, `create_transaction`, `account_alias`, +`allocate_resources`, `derive_entropy`) through an `Arc` +handle with an `AuthoritySession` snapshot the role revalidates before touching +key material. + +### Permission flow + +Permission grants are scoped by product id and typed request, so a grant for +one product never authorizes another product or another permission class. + +```text +Product app +(product_id = "my-product") + | + | host API call + | e.g. getUserId(), chain submit, camera, remote fetch + v +Generated product client / host callback bridge + | + v +ProductRuntime + | + | attaches current ProductContext.product_id + v +PermissionsService(storage, platform, product_id) + | + | builds storage key: + | + | CoreStorageKey::PermissionAuthorization { + | product_id: "my-product", + | request: PermissionAuthorizationRequest::... + | } + v +CoreStorage lookup + | + +-- Authorized ---------------> allow protected host/backend call + | + +-- Denied -------------------> return PermissionDenied / deny call + | + +-- NotDetermined / missing ---+ + | + v + Platform prompt callback + | + +-------------------+-------------------+ + | | | + v v v + device_permission() remote_permission() confirm_user_action() + camera/mic/etc chain/preimage/etc identity disclosure + | | | + +-------------------+-------------------+ + | + v + user chooses Allow / Deny + | + v + write Authorized / Denied to CoreStorage + under the same product-scoped key + | + +-------------+-------------+ + | | + v v + Authorized Denied + allow call deny call +``` + +Permission administration uses the same key without prompting: + +```text +Product UI + | + | permission_authorization_status(request) + | set_permission_authorization_status(request, status) + v +HostAdmin / ProductRuntime + | + v +PermissionsService + | + v +CoreStorageKey::PermissionAuthorization { product_id, request } +``` + +The embedder builds a role handle, `PairingHostRuntime::new(...)` or +`SigningHostRuntime::new(...)`, then calls `product_runtime(product, sink)` for +each product connection. Role-specific operations live only on the matching handle: +`cancel_pairing` and `notify_session_store_changed` on the pairing handle, +`activate_local_session` on the signing handle. Calling the wrong operation is +a compile error, not a runtime `Unavailable`. + +### The two roles + +Both implement the role-neutral **`ProductAuthority`** trait; each owns its +role-specific lifecycle, so no method exists on a role that can't mean it: + +- **`PairingHost`** (seedless): the user's keys live in an external wallet, so + signing/aliases/entropy relay over an encrypted SSO channel (statement store + on the People chain; the channel lives in `pairing_host/sso_channel.rs`). It + owns pairing/login state, persisted auth-session reload, and remote + signing-host liveness monitoring. +- **`SigningHost`** (wallet-local): signs on device from local BIP-39 entropy, + no pairing flow. `signing_host/local_activation.rs` establishes a session + from host-held secret material. Extrinsic signing / transaction construction / + ring-VRF aliases / resource allocation currently return `Unavailable` pending + chain-metadata and on-chain support. + +`host_logic` stays pure: the orchestrators above call into it for codecs, +session/SSO crypto, key derivation, and permission policy, while all I/O +(statement-store RPC, storage, prompts, chain RPC) stays in the layers above. ## Wire envelope diff --git a/rust/crates/truapi-server/src/chain_runtime.rs b/rust/crates/truapi-server/src/chain_runtime.rs index e77afbdd..8b31bc16 100644 --- a/rust/crates/truapi-server/src/chain_runtime.rs +++ b/rust/crates/truapi-server/src/chain_runtime.rs @@ -12,21 +12,22 @@ //! (`Unsupported`, `HostFailure`, ...). This avoids leaking json-rpc plumbing //! into the public API. -// Temporary for this stack layer: runtime wiring lands in the next child PR. -#![allow(dead_code)] - use core::pin::Pin; use core::task::{Context, Poll}; use std::collections::HashMap; use std::sync::Arc; use std::sync::Mutex; +#[cfg(not(target_arch = "wasm32"))] +use std::time::Duration; +#[cfg(target_arch = "wasm32")] +use web_time::Duration; use futures::FutureExt; use futures::channel::mpsc; use futures::future::{AbortHandle, Abortable}; use futures::future::{BoxFuture, Shared}; use futures::stream::BoxStream; -use futures::{Stream, StreamExt}; +use futures::{Stream, StreamExt, pin_mut}; use parity_scale_codec::{Decode, Error as ScaleError, Input}; use primitive_types::H256; use serde::de::{Deserializer, Error as DeError}; @@ -1110,6 +1111,129 @@ pub(crate) fn encode_hex(value: &[u8]) -> String { format!("0x{}", hex::encode(value)) } +/// Wait for a usable best block hash from a `chainHead_v1_follow` stream. +pub(crate) async fn wait_for_chain_head_best_hash( + follow: &mut BoxStream<'static, RemoteChainHeadFollowItem>, + label: &'static str, + initialization_timeout: Duration, + best_hash_timeout: Duration, +) -> Result, String> { + let timeout = futures_timer::Delay::new(initialization_timeout).fuse(); + pin_mut!(timeout); + loop { + let next = follow.next().fuse(); + pin_mut!(next); + futures::select! { + item = next => match item { + Some(RemoteChainHeadFollowItem::Initialized { finalized_block_hashes, .. }) => { + let fallback = finalized_block_hashes.last().cloned(); + return wait_for_chain_head_best_hash_after_initialization( + follow, + label, + fallback, + best_hash_timeout, + ) + .await; + } + Some(RemoteChainHeadFollowItem::BestBlockChanged { best_block_hash }) => { + return Ok(best_block_hash); + } + Some(RemoteChainHeadFollowItem::Stop) | None => { + return Err(format!("{label} follow stopped before initialization")); + } + _ => {} + }, + () = timeout => return Err(format!("{label} follow initialization timed out")), + } + } +} + +async fn wait_for_chain_head_best_hash_after_initialization( + follow: &mut BoxStream<'static, RemoteChainHeadFollowItem>, + label: &'static str, + fallback: Option>, + timeout: Duration, +) -> Result, String> { + let timeout = futures_timer::Delay::new(timeout).fuse(); + pin_mut!(timeout); + let mut candidate = fallback; + loop { + let next = follow.next().fuse(); + pin_mut!(next); + futures::select! { + item = next => match item { + Some(RemoteChainHeadFollowItem::BestBlockChanged { best_block_hash }) => { + return Ok(best_block_hash); + } + Some(RemoteChainHeadFollowItem::NewBlock { block_hash, .. }) => { + candidate = Some(block_hash); + } + Some(RemoteChainHeadFollowItem::Stop) | None => { + return candidate.ok_or_else(|| { + format!("{label} follow stopped before best block") + }); + } + _ => {} + }, + () = timeout => { + return candidate.ok_or_else(|| { + format!("{label} follow best block timed out") + }); + }, + } + } +} + +/// Wait for one storage operation's value from a `chainHead_v1_follow` stream. +pub(crate) async fn wait_for_chain_head_storage_value( + follow: &mut BoxStream<'static, RemoteChainHeadFollowItem>, + operation_id: &str, + key: &[u8], + label: &'static str, + timeout: Duration, +) -> Result>, String> { + let timeout = futures_timer::Delay::new(timeout).fuse(); + pin_mut!(timeout); + let mut value = None; + loop { + let next = follow.next().fuse(); + pin_mut!(next); + futures::select! { + item = next => match item { + Some(RemoteChainHeadFollowItem::OperationStorageItems { operation_id: item_operation_id, items }) + if item_operation_id == operation_id => + { + for item in items { + if item.key == key { + value = item.value; + } + } + } + Some(RemoteChainHeadFollowItem::OperationStorageDone { operation_id: item_operation_id }) + if item_operation_id == operation_id => + { + return Ok(value); + } + Some(RemoteChainHeadFollowItem::OperationInaccessible { operation_id: item_operation_id }) + if item_operation_id == operation_id => + { + return Ok(None); + } + Some(RemoteChainHeadFollowItem::OperationError { operation_id: item_operation_id, error }) + if item_operation_id == operation_id => + { + return Err(error); + } + Some(RemoteChainHeadFollowItem::Stop) | None => { + return Err(format!("{label} follow stopped during storage lookup")); + } + _ => {} + }, + () = timeout => return Err(format!("{label} storage lookup timed out")), + } + } +} + #[cfg(test)] fn decode_hex(value: &str) -> Result, String> { hex::decode(value.strip_prefix("0x").unwrap_or(value)).map_err(|_| "invalid hex".to_string()) @@ -1120,7 +1244,7 @@ mod tests { use super::*; use async_trait::async_trait; use futures::channel::mpsc as fut_mpsc; - use futures::stream::BoxStream; + use futures::stream::{self, BoxStream}; use std::sync::atomic::{AtomicUsize, Ordering}; fn spawner_for_tests() -> Spawner { @@ -1134,6 +1258,57 @@ mod tests { } } + #[test] + fn chain_head_best_hash_prefers_best_block_after_initialization() { + let mut follow = stream::iter(vec![ + RemoteChainHeadFollowItem::Initialized { + finalized_block_hashes: vec![vec![0x01]], + finalized_block_runtime: None, + }, + RemoteChainHeadFollowItem::BestBlockChanged { + best_block_hash: vec![0x02], + }, + ]) + .boxed(); + + let hash = futures::executor::block_on(wait_for_chain_head_best_hash( + &mut follow, + "test chain", + Duration::from_secs(10), + Duration::from_secs(2), + )) + .expect("best hash should resolve"); + + assert_eq!(hash, vec![0x02]); + } + + #[test] + fn chain_head_best_hash_uses_new_block_before_stale_finalized_fallback() { + let mut follow = stream::iter(vec![ + RemoteChainHeadFollowItem::Initialized { + finalized_block_hashes: vec![vec![0x01]], + finalized_block_runtime: None, + }, + RemoteChainHeadFollowItem::NewBlock { + block_hash: vec![0x03], + parent_block_hash: vec![0x01], + new_runtime: None, + }, + RemoteChainHeadFollowItem::Stop, + ]) + .boxed(); + + let hash = futures::executor::block_on(wait_for_chain_head_best_hash( + &mut follow, + "test chain", + Duration::from_secs(10), + Duration::from_secs(2), + )) + .expect("new block hash should resolve"); + + assert_eq!(hash, vec![0x03]); + } + #[derive(Default)] struct UnavailableChainProvider; diff --git a/rust/crates/truapi-server/src/core.rs b/rust/crates/truapi-server/src/core.rs new file mode 100644 index 00000000..d3e7a437 --- /dev/null +++ b/rust/crates/truapi-server/src/core.rs @@ -0,0 +1,366 @@ +//! Internal dispatcher/runtime core. +//! +//! Public host adapters should wrap this through [`crate::ProductRuntime`], which +//! owns the stable byte-frame ingress/egress and lifecycle API. + +use std::sync::{Arc, Mutex}; + +use parity_scale_codec::{Decode, Encode}; +use tracing::instrument; +use truapi::api::TrUApi; +use truapi_platform::{PairingHostConfig, Platform, ProductContext}; + +use crate::dispatcher::Dispatcher; +use crate::frame::ProtocolMessage; +use crate::generated::dispatcher; +use crate::host_logic::session::SessionState; +use crate::runtime::{PairingHostRole, ProductAuthority, ProductRuntimeHost, RuntimeServices}; +use crate::subscription::Spawner; +use crate::transport::Transport; + +/// Top-level core. Owns the generated dispatcher. +pub struct TrUApiCore { + dispatcher: Dispatcher, + session_state: Arc, +} + +impl TrUApiCore { + /// Build a core around a direct `TrUApi` implementation. The session + /// state holder is unused on this path (no platform pushes updates), + /// but is created anyway so the public API surface stays consistent. + /// Subscription work runs on `spawner`. + #[instrument(skip_all, fields(runtime.method = "core.new"))] + pub fn new

(host: Arc

, spawner: Spawner) -> Self + where + P: TrUApi + 'static, + { + let mut dispatcher = Dispatcher::new(spawner); + dispatcher::register(&mut dispatcher, host); + Self { + dispatcher, + session_state: SessionState::new(), + } + } + + /// Build a product-facing core around a [`Platform`] implementation, + /// explicit host runtime config, and product context. + #[instrument(skip_all, fields(runtime.method = "core.from_platform_with_config"))] + pub fn from_platform_with_config

( + platform: Arc

, + host_config: PairingHostConfig, + product: ProductContext, + spawner: Spawner, + ) -> Self + where + P: Platform + 'static, + { + let platform: Arc = platform; + let services = RuntimeServices::new( + platform, + host_config.people_chain_genesis_hash, + spawner.clone(), + ); + let pairing_host = PairingHostRole::new(services.clone(), host_config); + pairing_host.clone().start_session_store_sync(spawner); + Self::from_runtime_parts(services, pairing_host, product) + } + + /// Build a product-facing core from shared services and authority. + #[instrument(skip_all, fields(runtime.method = "core.from_runtime_parts"))] + pub(crate) fn from_runtime_parts( + services: Arc, + authority: Arc, + product: ProductContext, + ) -> Self { + let runtime = Arc::new(ProductRuntimeHost::from_services( + services.clone(), + authority.clone(), + product, + )); + Self::from_product_runtime(runtime, services.spawner.clone(), authority.session_state()) + } + + /// Build a dispatcher core around an already-created product runtime. + #[instrument(skip_all, fields(runtime.method = "core.from_product_runtime"))] + pub(crate) fn from_product_runtime( + runtime: Arc, + spawner: Spawner, + session_state: Arc, + ) -> Self { + let mut dispatcher = Dispatcher::new(spawner); + dispatcher::register(&mut dispatcher, runtime); + Self { + dispatcher, + session_state, + } + } + + /// Handle to the shared session-state holder used by subscriptions and + /// tests. Real host lifecycle flows through CoreStorage session sync and + /// `disconnect`. + pub fn session_state(&self) -> Arc { + self.session_state.clone() + } + + /// Decode an incoming product frame, run it through the dispatcher, and + /// return the SCALE-encoded response frame when the method has one. + #[instrument(skip_all, fields(runtime.method = "core.receive_from_product"))] + pub async fn receive_from_product(&self, frame: &[u8]) -> Option> { + let message = ProtocolMessage::decode(&mut &*frame).ok()?; + let transport = Arc::new(ResponseTransport::default()); + self.dispatcher + .dispatch(message, transport.clone() as Arc) + .await; + transport.take().map(|response| response.encode()) + } + + /// Dispatch an already-decoded protocol message through the underlying + /// dispatcher. Bridges that own a long-lived transport (e.g. WebSocket, + /// JS callback) call this directly so subscription items flow back + /// through the bridge transport instead of the single-slot capture used + /// by [`Self::receive_from_product`]. + #[instrument(skip_all, fields(runtime.method = "core.dispatch"))] + pub async fn dispatch(&self, message: ProtocolMessage, transport: Arc) { + self.dispatcher.dispatch(message, transport).await; + } + + /// Cancel all active and pending subscriptions owned by this core. + pub fn cancel_subscriptions(&self) { + self.dispatcher.cancel_subscriptions(); + } +} + +/// Single-slot transport that captures the next response the dispatcher +/// emits. Used by [`TrUApiCore::receive_from_product`] to bridge between the +/// dispatcher's push model and the one-response frame API exposed to embedders. +#[derive(Default)] +struct ResponseTransport { + response: Mutex>, +} + +impl ResponseTransport { + fn take(&self) -> Option { + self.response.lock().unwrap().take() + } +} + +impl Transport for ResponseTransport { + fn send(&self, message: ProtocolMessage) { + *self.response.lock().unwrap() = Some(message); + } + + fn on_message( + &self, + _handler: Box, + ) -> Box { + Box::new(|| {}) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use parity_scale_codec::Encode; + use truapi::v01; + use truapi::versioned::local_storage::{ + HostLocalStorageClearRequest, HostLocalStorageReadRequest, HostLocalStorageWriteRequest, + }; + use truapi::versioned::notifications::HostPushNotificationRequest; + use truapi::versioned::permissions::RemotePermissionRequest; + use truapi::versioned::system::HostFeatureSupportedRequest; + + use crate::frame::{Payload, request_ids, subscription_ids}; + use crate::test_support::{StubPlatform, runtime_config, test_spawner}; + + #[test] + fn from_platform_dispatches_feature_supported() { + let (host_config, product) = runtime_config("dotli.dot"); + let core = TrUApiCore::from_platform_with_config( + Arc::new(StubPlatform::default()), + host_config, + product, + test_spawner(), + ); + let request = HostFeatureSupportedRequest::V1(v01::HostFeatureSupportedRequest::Chain { + genesis_hash: vec![0u8; 32], + }); + let ids = request_ids("system_feature_supported").expect("known request method"); + let frame = ProtocolMessage { + request_id: "p:1".into(), + payload: Payload { + id: ids.request_id, + value: request.encode(), + }, + }; + let encoded = frame.encode(); + let response_bytes = futures::executor::block_on(core.receive_from_product(&encoded)) + .expect("dispatcher should emit a response"); + let response = ProtocolMessage::decode(&mut &response_bytes[..]).expect("decode response"); + assert_eq!(response.request_id, "p:1"); + assert_eq!(response.payload.id, ids.response_id); + // Wire payload is `Result`-shaped: + // [Ok disc=0x00][V1 variant 0x00][supported=1] + assert_eq!(response.payload.value, vec![0x00, 0x00, 0x01]); + } + + /// Drive a request frame through `TrUApiCore::receive_from_product`, + /// decode the response envelope, and return its payload bytes (without + /// the wrapping ProtocolMessage). Shared by the runtime-delegation + /// tests below. + fn run_request(core: &TrUApiCore, method: &str, request_bytes: Vec) -> Vec { + let ids = request_ids(method).expect("known request method"); + let frame = ProtocolMessage { + request_id: "p:1".into(), + payload: Payload { + id: ids.request_id, + value: request_bytes, + }, + }; + let response_bytes = + futures::executor::block_on(core.receive_from_product(&frame.encode())) + .expect("dispatcher should emit a response"); + let response = ProtocolMessage::decode(&mut &response_bytes[..]).expect("decode response"); + assert_eq!(response.request_id, "p:1"); + assert_eq!(response.payload.id, ids.response_id); + response.payload.value + } + + fn make_core() -> TrUApiCore { + let (host_config, product) = runtime_config("dotli.dot"); + TrUApiCore::from_platform_with_config( + Arc::new(StubPlatform::default()), + host_config, + product, + test_spawner(), + ) + } + + #[test] + fn local_storage_read_round_trips_none() { + let core = make_core(); + let request = HostLocalStorageReadRequest::V1(v01::HostLocalStorageReadRequest { + key: "missing".into(), + }); + let payload = run_request(&core, "local_storage_read", request.encode()); + // Ok disc 0x00, V1 variant 0x00, Option::None = 0x00. + assert_eq!(payload, vec![0x00, 0x00, 0x00]); + } + + #[test] + fn local_storage_write_round_trips_unit_ok() { + let core = make_core(); + let request = HostLocalStorageWriteRequest::V1(v01::HostLocalStorageWriteRequest { + key: "k".into(), + value: vec![1, 2, 3], + }); + let payload = run_request(&core, "local_storage_write", request.encode()); + // Ok disc 0x00, V1 variant 0x00. + assert_eq!(payload, vec![0x00, 0x00]); + } + + #[test] + fn local_storage_clear_round_trips_unit_ok() { + let core = make_core(); + let request = + HostLocalStorageClearRequest::V1(v01::HostLocalStorageClearRequest { key: "k".into() }); + let payload = run_request(&core, "local_storage_clear", request.encode()); + // Ok disc 0x00, V1 variant 0x00. + assert_eq!(payload, vec![0x00, 0x00]); + } + + #[test] + fn send_push_notification_delegates_to_platform() { + let core = make_core(); + let request = HostPushNotificationRequest::V1(v01::HostPushNotificationRequest { + text: "hi".into(), + deeplink: None, + scheduled_at: None, + }); + let payload = run_request( + &core, + "notifications_send_push_notification", + request.encode(), + ); + // Ok disc 0x00, V1 variant 0x00, notification id 0. + let mut expected = vec![0x00u8]; + truapi::versioned::notifications::HostPushNotificationResponse::V1( + v01::HostPushNotificationResponse { id: 0 }, + ) + .encode_to(&mut expected); + assert_eq!(payload, expected); + } + + #[test] + fn request_remote_permission_round_trips_granted() { + let core = make_core(); + let request = RemotePermissionRequest::V1(v01::RemotePermissionRequest { + permission: v01::RemotePermission::ChainSubmit, + }); + let payload = run_request( + &core, + "permissions_request_remote_permission", + request.encode(), + ); + // Stub permissions grants every request. Wire is Ok disc 0x00, V1 + // variant 0x00, granted=1. + assert_eq!(payload, vec![0x00, 0x00, 0x01]); + } + + /// `connection_status_subscribe` produces a stream whose first item is + /// the current session state. Drive it through the dispatcher with a + /// recording transport and assert exactly one `_receive` frame appears. + #[test] + fn connection_status_subscribe_yields_initial_disconnected() { + use std::sync::Mutex; + + #[derive(Default)] + struct RecordingTransport { + sent: Mutex>, + } + impl Transport for RecordingTransport { + fn send(&self, message: ProtocolMessage) { + self.sent.lock().unwrap().push(message); + } + fn on_message( + &self, + _handler: Box, + ) -> Box { + Box::new(|| {}) + } + } + + let core = make_core(); + let transport = Arc::new(RecordingTransport::default()); + let dyn_transport: Arc = transport.clone(); + + let sub_ids = + subscription_ids("account_connection_status_subscribe").expect("known subscription"); + let frame = ProtocolMessage { + request_id: "p:1".into(), + payload: Payload { + id: sub_ids.start_id, + value: Vec::new(), + }, + }; + futures::executor::block_on(core.dispatch(frame, dyn_transport)); + + // Wait briefly for the spawned thread to emit the initial item. + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); + loop { + if !transport.sent.lock().unwrap().is_empty() { + break; + } + if std::time::Instant::now() > deadline { + panic!("subscription did not yield an item in time"); + } + std::thread::sleep(std::time::Duration::from_millis(10)); + } + + let sent = transport.sent.lock().unwrap().clone(); + assert!(!sent.is_empty(), "expected at least one _receive frame"); + let first = &sent[0]; + assert_eq!(first.payload.id, sub_ids.receive_id); + // V1(Disconnected): V1 variant 0x00, Disconnected discriminant 0x00. + assert_eq!(first.payload.value, vec![0x00, 0x00]); + } +} diff --git a/rust/crates/truapi-server/src/dispatcher.rs b/rust/crates/truapi-server/src/dispatcher.rs index da27c8c0..e0c3db60 100644 --- a/rust/crates/truapi-server/src/dispatcher.rs +++ b/rust/crates/truapi-server/src/dispatcher.rs @@ -162,6 +162,11 @@ impl Dispatcher { // Unknown discriminant: drop. Response / receive / interrupt frames are // handled by the client side and never registered here. } + + /// Cancel every subscription currently owned by this dispatcher. + pub fn cancel_subscriptions(&self) { + self.subscriptions.cancel_all(); + } } #[cfg(test)] diff --git a/rust/crates/truapi-server/src/host_core.rs b/rust/crates/truapi-server/src/host_core.rs new file mode 100644 index 00000000..8478467c --- /dev/null +++ b/rust/crates/truapi-server/src/host_core.rs @@ -0,0 +1,589 @@ +//! Stable host-embedding API for the TrUAPI server runtime. +//! +//! `ProductRuntime` is the target-neutral boundary embedders should use. +//! Platform adapters provide: +//! - a [`truapi_platform::Platform`] implementation for host callbacks, +//! - a task [`Spawner`] for runtime-owned async work, +//! - a [`FrameSink`] for outgoing protocol frames. +//! +//! Target-specific shells such as wasm-bindgen, iOS FFI, or desktop IPC should +//! keep their conversion code outside this module. + +use std::collections::HashMap; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::{Arc, Mutex}; + +use futures::future::{AbortHandle, Abortable}; +use parity_scale_codec::{Decode, Encode}; +use thiserror::Error; +use tracing::instrument; +use truapi::v01; +use truapi_platform::{ + CoreAdmin, PairingHostAdmin, PairingHostConfig, PermissionAuthorizationRequest, + PermissionAuthorizationStatus, Platform, ProductContext, SigningHostConfig, +}; + +use crate::core::TrUApiCore; +use crate::frame::ProtocolMessage; +use crate::runtime::{ + LocalActivation, PairingHostRole, ProductAuthority, ProductRuntimeHost, RuntimeServices, + SigningHostRole, +}; +use crate::subscription::Spawner; +use crate::transport::Transport; + +/// Outgoing frame sink owned by a host adapter. +/// +/// Implementations bridge encoded TrUAPI protocol frames to their target +/// transport: JS callbacks, native callbacks, IPC, channels, or another +/// host-specific mechanism. +pub trait FrameSink: Send + Sync { + /// Emit one SCALE-encoded [`ProtocolMessage`] frame. + fn emit_frame(&self, frame: Vec); +} + +/// Errors returned by [`ProductRuntime::receive_frame`]. +#[derive(Debug, Error)] +pub enum ProductRuntimeError { + /// Incoming bytes did not decode as a protocol frame. + #[error("invalid frame: {reason}")] + InvalidFrame { + /// Decode failure reason. + reason: String, + }, +} + +fn product_context(product_id: &str) -> Result { + ProductContext::new(product_id.to_string()).map_err(|err| v01::GenericError { + reason: err.to_string(), + }) +} + +/// A seedless pairing host: the user's keys live in an external wallet reached +/// over the SSO pairing channel. +/// +/// Owns the shared services plus pairing-host state. Local-session activation +/// is a signing-host operation and is not present here. +pub struct PairingHostRuntime { + services: Arc, + pairing_host: Arc, +} + +impl PairingHostRuntime { + /// Build a long-lived pairing-host runtime around a platform implementation. + #[instrument(skip_all, fields(runtime.method = "pairing_host_runtime.new"))] + pub fn new

(platform: Arc

, config: PairingHostConfig, spawner: Spawner) -> Self + where + P: Platform + 'static, + { + let platform: Arc = platform; + let services = RuntimeServices::new( + platform.clone(), + config.people_chain_genesis_hash, + spawner.clone(), + ); + let pairing_host = PairingHostRole::new(services.clone(), config); + pairing_host.clone().start_session_store_sync(spawner); + Self { + services, + pairing_host, + } + } + + /// Build a product-facing runtime from this pairing host. + #[instrument(skip_all, fields(runtime.method = "pairing_host_runtime.product_runtime"))] + pub fn product_runtime( + &self, + product: ProductContext, + sink: Arc, + ) -> ProductRuntime { + ProductRuntime::new( + self.services.clone(), + self.pairing_host.clone(), + product, + sink, + ) + } + + /// Build a product-scoped administration handle from this pairing host. + #[instrument(skip_all, fields(runtime.method = "pairing_host_runtime.product_admin"))] + pub fn product_admin(&self, product: ProductContext) -> HostAdmin { + HostAdmin::new(self.services.clone(), self.pairing_host.clone(), product) + } + + /// Disconnect the active account-authority session. + #[instrument(skip_all, fields(runtime.method = "pairing_host_runtime.disconnect_session"))] + pub async fn disconnect_session(&self) { + self.pairing_host.disconnect().await; + } + + /// Cancel an in-flight SSO pairing request. A no-op when no pairing is + /// active. + #[instrument(skip_all, fields(runtime.method = "pairing_host_runtime.cancel_pairing"))] + pub fn cancel_pairing(&self) { + self.pairing_host.cancel_login(); + } + + /// Notify the pairing runtime that the persisted auth-session blob may + /// have changed and should be re-read. + #[instrument(skip_all, fields(runtime.method = "pairing_host_runtime.notify_session_store_changed"))] + pub fn notify_session_store_changed(&self) { + self.pairing_host.notify_session_store_changed(); + } + + /// Read a stored permission authorization status for a product without prompting. + #[instrument(skip_all, fields(runtime.method = "pairing_host_runtime.permission_authorization_status", product_id = %product_id))] + pub async fn permission_authorization_status( + &self, + product_id: &str, + request: PermissionAuthorizationRequest, + ) -> Result { + self.product_admin(product_context(product_id)?) + .permission_authorization_status(request) + .await + } + + /// Read stored permission authorization statuses for a product without prompting. + #[instrument(skip_all, fields(runtime.method = "pairing_host_runtime.permission_authorization_statuses", product_id = %product_id))] + pub async fn permission_authorization_statuses( + &self, + product_id: &str, + requests: Vec, + ) -> Result, v01::GenericError> { + self.product_admin(product_context(product_id)?) + .permission_authorization_statuses(requests) + .await + } + + /// Update a stored permission authorization status for a product. + #[instrument(skip_all, fields(runtime.method = "pairing_host_runtime.set_permission_authorization_status", product_id = %product_id))] + pub async fn set_permission_authorization_status( + &self, + product_id: &str, + request: PermissionAuthorizationRequest, + status: PermissionAuthorizationStatus, + ) -> Result<(), v01::GenericError> { + self.product_admin(product_context(product_id)?) + .set_permission_authorization_status(request, status) + .await + } +} + +impl PairingHostAdmin for PairingHostRuntime { + fn cancel_pairing(&self) { + PairingHostRuntime::cancel_pairing(self); + } + + fn notify_session_store_changed(&self) { + PairingHostRuntime::notify_session_store_changed(self); + } +} + +/// A wallet-local signing host: the user's keys are held on this device. +/// +/// Owns the shared services plus signing-host state. There is no pairing flow, +/// so pairing cancellation is not present here. +/// +/// Raw-bytes signing and product entropy are implemented; extrinsic-payload +/// signing, transaction construction, ring-VRF aliases, and resource allocation +/// return an `Unavailable` error pending chain-metadata and on-chain support. +pub struct SigningHostRuntime { + services: Arc, + signing_host: Arc, +} + +impl SigningHostRuntime { + /// Build a long-lived signing-host runtime around a platform implementation. + #[instrument(skip_all, fields(runtime.method = "signing_host_runtime.new"))] + pub fn new

(platform: Arc

, config: SigningHostConfig, spawner: Spawner) -> Self + where + P: Platform + 'static, + { + let platform: Arc = platform; + let services = + RuntimeServices::new(platform.clone(), config.people_chain_genesis_hash, spawner); + let signing_host = SigningHostRole::new(platform); + Self { + services, + signing_host, + } + } + + /// Build a product-facing runtime from this signing host. + #[instrument(skip_all, fields(runtime.method = "signing_host_runtime.product_runtime"))] + pub fn product_runtime( + &self, + product: ProductContext, + sink: Arc, + ) -> ProductRuntime { + ProductRuntime::new( + self.services.clone(), + self.signing_host.clone(), + product, + sink, + ) + } + + /// Build a product-scoped administration handle from this signing host. + #[instrument(skip_all, fields(runtime.method = "signing_host_runtime.product_admin"))] + pub fn product_admin(&self, product: ProductContext) -> HostAdmin { + HostAdmin::new(self.services.clone(), self.signing_host.clone(), product) + } + + /// Disconnect the active account-authority session. + #[instrument(skip_all, fields(runtime.method = "signing_host_runtime.disconnect_session"))] + pub async fn disconnect_session(&self) { + self.signing_host.disconnect().await; + } + + /// Activate a wallet-local session from host-held secret material (raw + /// BIP-39 entropy). + #[instrument(skip_all, fields(runtime.method = "signing_host_runtime.activate_local_session"))] + pub async fn activate_local_session(&self, secret: Vec) -> Result<(), v01::GenericError> { + self.signing_host + .activate_local_session(secret) + .await + .map_err(|err| v01::GenericError { + reason: err.reason(), + }) + } +} + +/// Product-scoped administration handle for host UI. +/// +/// Host UI should use this when it needs to inspect or update core-owned state +/// without owning a product frame endpoint. +pub struct HostAdmin { + authority: Arc, + product_runtime: Arc, +} + +impl HostAdmin { + /// Build an admin handle from a long-lived host runtime. + #[instrument(skip_all, fields(runtime.method = "host_admin.new"))] + pub(crate) fn new( + services: Arc, + authority: Arc, + product: ProductContext, + ) -> Self { + let product_runtime = Arc::new(ProductRuntimeHost::from_services( + services, + authority.clone(), + product, + )); + Self { + authority, + product_runtime, + } + } + + /// Core-owned logout/disconnect. + #[instrument(skip_all, fields(runtime.method = "host_admin.disconnect_session"))] + pub async fn disconnect_session(&self) { + self.authority.disconnect().await; + } + + /// Read a stored permission authorization status without prompting. + #[instrument(skip_all, fields(runtime.method = "host_admin.permission_authorization_status"))] + pub async fn permission_authorization_status( + &self, + request: PermissionAuthorizationRequest, + ) -> Result { + self.product_runtime + .permission_authorization_status(request) + .await + } + + /// Read stored permission authorization statuses without prompting. + #[instrument(skip_all, fields(runtime.method = "host_admin.permission_authorization_statuses"))] + pub async fn permission_authorization_statuses( + &self, + requests: Vec, + ) -> Result, v01::GenericError> { + self.product_runtime + .permission_authorization_statuses(requests) + .await + } + + /// Update a stored permission authorization status. + #[instrument(skip_all, fields(runtime.method = "host_admin.set_permission_authorization_status"))] + pub async fn set_permission_authorization_status( + &self, + request: PermissionAuthorizationRequest, + status: PermissionAuthorizationStatus, + ) -> Result<(), v01::GenericError> { + self.product_runtime + .set_permission_authorization_status(request, status) + .await + } +} + +#[truapi_platform::async_trait] +impl CoreAdmin for HostAdmin { + async fn disconnect_session(&self) -> Result<(), v01::GenericError> { + HostAdmin::disconnect_session(self).await; + Ok(()) + } + + async fn get_permission_authorization_status( + &self, + request: PermissionAuthorizationRequest, + ) -> Result { + self.permission_authorization_status(request).await + } + + async fn get_permission_authorization_statuses( + &self, + requests: Vec, + ) -> Result, v01::GenericError> { + self.permission_authorization_statuses(requests).await + } + + async fn set_permission_authorization_status( + &self, + request: PermissionAuthorizationRequest, + status: PermissionAuthorizationStatus, + ) -> Result<(), v01::GenericError> { + HostAdmin::set_permission_authorization_status(self, request, status).await + } +} + +/// Target-neutral host runtime wrapper. +/// +/// `ProductRuntime` is product-scoped. It owns the dispatcher core for one product +/// connection and handles byte-frame ingress, response/subscription egress, and +/// in-flight dispatch cancellation on dispose. +pub struct ProductRuntime { + core: TrUApiCore, + admin: HostAdmin, + transport: Arc, + disposed: Arc, + in_flight: Mutex>, + next_dispatch_id: AtomicU64, +} + +impl ProductRuntime { + /// Build a product-facing host core around a platform implementation and + /// outgoing frame sink. + #[instrument(skip_all, fields(runtime.method = "product_runtime.from_platform_with_config"))] + pub fn from_platform_with_config

( + platform: Arc

, + host_config: PairingHostConfig, + product: ProductContext, + spawner: Spawner, + sink: Arc, + ) -> Self + where + P: Platform + 'static, + { + let pairing = PairingHostRuntime::new(platform, host_config, spawner); + pairing.product_runtime(product, sink) + } + + /// Build a product-facing runtime from shared services and an authority. + #[instrument(skip_all, fields(runtime.method = "product_runtime.new"))] + pub(crate) fn new( + services: Arc, + authority: Arc, + product: ProductContext, + sink: Arc, + ) -> Self { + let disposed = Arc::new(AtomicBool::new(false)); + let transport = Arc::new(SinkTransport { + sink, + disposed: disposed.clone(), + }); + let admin = HostAdmin::new(services.clone(), authority.clone(), product); + Self { + core: TrUApiCore::from_product_runtime( + admin.product_runtime.clone(), + services.spawner.clone(), + authority.session_state(), + ), + admin, + transport, + disposed, + in_flight: Mutex::new(HashMap::new()), + next_dispatch_id: AtomicU64::new(0), + } + } + + /// Push one SCALE-encoded protocol frame into the dispatcher. + /// + /// Calls after [`Self::dispose`] are ignored and return `Ok(())` without + /// decoding. If dispose happens while a dispatch is in flight, the dispatch + /// is aborted and this method still returns `Ok(())`. + #[instrument(skip_all, fields(runtime.method = "product_runtime.receive_frame"))] + pub async fn receive_frame(&self, frame: Vec) -> Result<(), ProductRuntimeError> { + if self.disposed.load(Ordering::Acquire) { + return Ok(()); + } + + let message = ProtocolMessage::decode(&mut frame.as_slice()).map_err(|err| { + ProductRuntimeError::InvalidFrame { + reason: err.to_string(), + } + })?; + let dispatch_id = self.next_dispatch_id.fetch_add(1, Ordering::Relaxed); + let (abort_handle, abort_registration) = AbortHandle::new_pair(); + self.in_flight + .lock() + .expect("host core in-flight dispatch mutex poisoned") + .insert(dispatch_id, abort_handle); + + let transport: Arc = self.transport.clone(); + let _ = Abortable::new(self.core.dispatch(message, transport), abort_registration).await; + + self.in_flight + .lock() + .expect("host core in-flight dispatch mutex poisoned") + .remove(&dispatch_id); + if self.disposed.load(Ordering::Acquire) { + self.core.cancel_subscriptions(); + } + Ok(()) + } + + /// Core-owned logout/disconnect. Best-effort notifies the SSO peer when + /// the session has channel material, then clears in-memory and persisted + /// session state. + #[instrument(skip_all, fields(runtime.method = "product_runtime.disconnect_session"))] + pub async fn disconnect_session(&self) { + self.admin.disconnect_session().await; + } + + /// Read a stored permission authorization status without prompting. + #[instrument(skip_all, fields(runtime.method = "product_runtime.permission_authorization_status"))] + pub async fn permission_authorization_status( + &self, + request: PermissionAuthorizationRequest, + ) -> Result { + self.admin.permission_authorization_status(request).await + } + + /// Read stored permission authorization statuses without prompting. + #[instrument(skip_all, fields(runtime.method = "product_runtime.permission_authorization_statuses"))] + pub async fn permission_authorization_statuses( + &self, + requests: Vec, + ) -> Result, v01::GenericError> { + self.admin.permission_authorization_statuses(requests).await + } + + /// Update a stored permission authorization status. `NotDetermined` + /// clears the stored value so the next product request prompts again. + #[instrument(skip_all, fields(runtime.method = "product_runtime.set_permission_authorization_status"))] + pub async fn set_permission_authorization_status( + &self, + request: PermissionAuthorizationRequest, + status: PermissionAuthorizationStatus, + ) -> Result<(), v01::GenericError> { + self.admin + .set_permission_authorization_status(request, status) + .await + } + + /// Dispose this host core. Idempotent. + /// + /// Disposal suppresses future outgoing frames, aborts in-flight dispatch + /// futures, and cancels active subscriptions. + #[instrument(skip_all, fields(runtime.method = "product_runtime.dispose"))] + pub fn dispose(&self) { + if self.disposed.swap(true, Ordering::AcqRel) { + return; + } + for (_, handle) in self + .in_flight + .lock() + .expect("host core in-flight dispatch mutex poisoned") + .drain() + { + handle.abort(); + } + self.core.cancel_subscriptions(); + } +} + +struct SinkTransport { + sink: Arc, + disposed: Arc, +} + +impl Transport for SinkTransport { + fn send(&self, message: ProtocolMessage) { + if self.disposed.load(Ordering::Acquire) { + return; + } + self.sink.emit_frame(message.encode()); + } + + fn on_message( + &self, + _handler: Box, + ) -> Box { + Box::new(|| {}) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::frame::{Payload, ProtocolMessage, subscription_ids}; + use crate::test_support::{StubPlatform, runtime_config, test_spawner}; + use parity_scale_codec::Encode; + use std::sync::atomic::Ordering; + + #[derive(Default)] + struct RecordingSink { + frames: Mutex>>, + } + + impl FrameSink for RecordingSink { + fn emit_frame(&self, frame: Vec) { + self.frames + .lock() + .expect("recording sink mutex poisoned") + .push(frame); + } + } + + #[test] + fn dispose_cancels_active_subscriptions() { + let theme_stream_dropped = Arc::new(AtomicBool::new(false)); + let platform = Arc::new(StubPlatform { + theme_stream_pending: true, + theme_stream_dropped: theme_stream_dropped.clone(), + ..Default::default() + }); + let sink = Arc::new(RecordingSink::default()); + let (host_config, product) = runtime_config("myapp.dot"); + let runtime = ProductRuntime::from_platform_with_config( + platform, + host_config, + product, + test_spawner(), + sink, + ); + + let ids = subscription_ids("theme_subscribe").expect("known subscription"); + let frame = ProtocolMessage { + request_id: "theme:1".to_string(), + payload: Payload { + id: ids.start_id, + value: Vec::new(), + }, + }; + futures::executor::block_on(runtime.receive_frame(frame.encode())).unwrap(); + + runtime.dispose(); + + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); + while !theme_stream_dropped.load(Ordering::SeqCst) { + assert!( + std::time::Instant::now() < deadline, + "dispose did not drop the active theme subscription stream" + ); + std::thread::sleep(std::time::Duration::from_millis(5)); + } + } +} diff --git a/rust/crates/truapi-server/src/host_logic/permissions.rs b/rust/crates/truapi-server/src/host_logic/permissions.rs index b1189689..d1414096 100644 --- a/rust/crates/truapi-server/src/host_logic/permissions.rs +++ b/rust/crates/truapi-server/src/host_logic/permissions.rs @@ -7,6 +7,9 @@ //! The cache layer is shared but keys are typed so a device grant cannot //! authorize a remote operation by accident. Keys are also scoped by product id //! so one product's authorization never grants another product's request. +//! Identity disclosure is also represented as a product-scoped authorization, +//! but the prompt itself is handled by the account runtime because it uses the +//! richer user-confirmation surface rather than the device/remote callbacks. use parity_scale_codec::{Decode, Encode}; @@ -104,6 +107,13 @@ impl<'a, S: CoreStorage + ?Sized, P: Permissions + ?Sized> PermissionsService<'a self.peek_device(permission).await } PermissionAuthorizationRequest::Remote(request) => self.peek_remote(request).await, + PermissionAuthorizationRequest::IdentityDisclosure => { + authorization_status( + self.storage, + identity_disclosure_core_storage_key(self.product_id), + ) + .await + } } } @@ -136,6 +146,9 @@ impl<'a, S: CoreStorage + ?Sized, P: Permissions + ?Sized> PermissionsService<'a PermissionAuthorizationRequest::Remote(request) => { remote_core_storage_key(self.product_id, request) } + PermissionAuthorizationRequest::IdentityDisclosure => { + identity_disclosure_core_storage_key(self.product_id) + } }; set_authorization_status(self.storage, key, status).await } @@ -249,6 +262,13 @@ fn remote_core_storage_key(product_id: &str, request: &RemotePermissionRequest) } } +fn identity_disclosure_core_storage_key(product_id: &str) -> CoreStorageKey { + CoreStorageKey::PermissionAuthorization { + product_id: product_id.to_string(), + request: PermissionAuthorizationRequest::IdentityDisclosure, + } +} + fn canonical_remote_request(request: &RemotePermissionRequest) -> RemotePermissionRequest { let permission = match &request.permission { RemotePermission::Remote { domains } => { @@ -366,9 +386,14 @@ mod tests { permission: RemotePermission::ChainSubmit, }, ); + let identity = identity_disclosure_core_storage_key("product.dot"); + let other_product_identity = identity_disclosure_core_storage_key("other.dot"); assert_ne!(camera, other_product); assert_ne!(camera, remote); + assert_ne!(camera, identity); + assert_ne!(remote, identity); + assert_ne!(identity, other_product_identity); } #[test] @@ -585,6 +610,35 @@ mod tests { ); } + #[test] + fn identity_disclosure_authorization_round_trips() { + let storage = MemStorage::default(); + let prompt = ScriptedPrompt::new(vec![], vec![]); + let service = PermissionsService::new(&storage, &prompt, "product.dot"); + let request = PermissionAuthorizationRequest::IdentityDisclosure; + + assert_eq!( + futures::executor::block_on(service.authorization_status(&request)).unwrap(), + PermissionAuthorizationStatus::NotDetermined + ); + + futures::executor::block_on( + service.set_authorization_status(&request, PermissionAuthorizationStatus::Authorized), + ) + .unwrap(); + assert_eq!( + futures::executor::block_on(service.authorization_status(&request)).unwrap(), + PermissionAuthorizationStatus::Authorized + ); + + let other_product_service = PermissionsService::new(&storage, &prompt, "other.dot"); + assert_eq!( + futures::executor::block_on(other_product_service.authorization_status(&request)) + .unwrap(), + PermissionAuthorizationStatus::NotDetermined + ); + } + /// Prompt callback that always errors, to exercise the transient-failure /// path (fail closed for the current call, but do not persist the error). struct FailingPrompt; diff --git a/rust/crates/truapi-server/src/host_logic/session.rs b/rust/crates/truapi-server/src/host_logic/session.rs index 3eb51ff8..7eb9e46d 100644 --- a/rust/crates/truapi-server/src/host_logic/session.rs +++ b/rust/crates/truapi-server/src/host_logic/session.rs @@ -38,6 +38,32 @@ pub struct SessionInfo { pub full_username: Option, } +impl SessionInfo { + /// Whether the session already carries a usable username. + pub(crate) fn has_username(&self) -> bool { + non_empty_username(&self.full_username) || non_empty_username(&self.lite_username) + } + + /// Apply resolved username fields without replacing populated values with + /// empty strings. + pub(crate) fn apply_usernames( + &mut self, + lite_username: Option, + full_username: Option, + ) { + if non_empty_username(&full_username) { + self.full_username = full_username; + } + if non_empty_username(&lite_username) { + self.lite_username = lite_username; + } + } +} + +fn non_empty_username(value: &Option) -> bool { + value.as_ref().is_some_and(|value| !value.is_empty()) +} + /// SSO session material negotiated by the pairing host with the signing host. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] pub struct SsoSessionInfo { @@ -197,6 +223,24 @@ mod tests { } } + #[test] + fn session_username_helpers_check_and_apply_non_empty_values() { + let mut session = info(0x42); + session.lite_username = None; + session.full_username = None; + + assert!(!session.has_username()); + + session.apply_usernames(Some(String::new()), Some("Alice Smith".to_string())); + assert!(session.has_username()); + assert_eq!(session.full_username.as_deref(), Some("Alice Smith")); + assert_eq!(session.lite_username, None); + + session.apply_usernames(Some("alice".to_string()), Some(String::new())); + assert_eq!(session.full_username.as_deref(), Some("Alice Smith")); + assert_eq!(session.lite_username.as_deref(), Some("alice")); + } + #[test] fn current_starts_empty() { let state = SessionState::new(); diff --git a/rust/crates/truapi-server/src/host_rpc_client.rs b/rust/crates/truapi-server/src/host_rpc_client.rs index ec959b14..0d46e586 100644 --- a/rust/crates/truapi-server/src/host_rpc_client.rs +++ b/rust/crates/truapi-server/src/host_rpc_client.rs @@ -5,9 +5,6 @@ //! [`subxt_rpcs::RpcClientT`]: request correlation, subscription routing, and //! best-effort unsubscribe on subscription drop. -// Temporary for this stack layer: runtime wiring lands in the next child PR. -#![allow(dead_code)] - use core::fmt; use core::mem; use core::pin::Pin; diff --git a/rust/crates/truapi-server/src/lib.rs b/rust/crates/truapi-server/src/lib.rs index 9708f559..9d2d8489 100644 --- a/rust/crates/truapi-server/src/lib.rs +++ b/rust/crates/truapi-server/src/lib.rs @@ -1,17 +1,41 @@ -//! TrUAPI server runtime support. +//! TrUAPI server runtime: dispatcher, frames, SCALE encoding, stream management. //! -//! This layer contains host-agnostic logic, wire-frame dispatch, and chain -//! JSON-RPC mechanics shared by the runtime and target adapters. Platform -//! runtime wiring is added by a later stack layer. - -#![forbid(unsafe_code)] +//! Hosts instantiate a role runtime around a [`truapi_platform::Platform`] +//! implementation, then create product-scoped [`ProductRuntime`] endpoints that +//! expose the stable byte-frame API used from WASM, native mobile, or desktop +//! shells. +//! +//! Host-facing bridges: +//! - `wasm` (wasm32 only): wasm-bindgen surface exposing `WasmProductRuntime`. pub(crate) mod chain_runtime; +pub mod core; pub(crate) mod dispatcher; pub mod frame; +pub(crate) mod host_core; pub mod host_logic; pub(crate) mod host_rpc_client; +pub mod logging; +pub(crate) mod runtime; pub mod subscription; pub mod transport; +#[cfg(test)] +pub(crate) mod test_support; + pub mod generated; + +#[cfg(target_arch = "wasm32")] +pub mod wasm; + +pub use host_core::{ + FrameSink, HostAdmin, PairingHostRuntime, ProductRuntime, ProductRuntimeError, + SigningHostRuntime, +}; +pub use truapi_platform::{ + HostRuntimeConfig, PairingHostConfig, PermissionAuthorizationRequest, + PermissionAuthorizationStatus, Platform, ProductContext, SigningHostConfig, +}; + +#[cfg(target_arch = "wasm32")] +pub use wasm::*; diff --git a/rust/crates/truapi-server/src/logging.rs b/rust/crates/truapi-server/src/logging.rs new file mode 100644 index 00000000..6141aad9 --- /dev/null +++ b/rust/crates/truapi-server/src/logging.rs @@ -0,0 +1,210 @@ +//! Level-controlled `tracing` output, routed to the host console. +//! +//! Events emitted via the `tracing` macros (`info!`, `debug!`, …) and +//! `#[instrument]` spans flow through a single subscriber installed once by +//! [`init`]. A reloadable [`LevelFilter`] decides what reaches the console, so +//! the verbosity is tunable at runtime via [`set_level`] (exposed to JS as +//! `setLogLevel`). Disabled by default ([`LevelFilter::OFF`]). +//! +//! On wasm each level maps to the matching `console` method +//! (`error`/`warn`/`info`/`debug`); on native everything goes to stderr. +//! In Chrome, `debug`/`trace` land on `console.debug`, which the DevTools +//! console hides unless its level dropdown includes "Verbose". +//! +//! Output is plaintext, so never log secret material (key bytes, session +//! tokens, signatures). + +use core::fmt::{self, Write as _}; +use std::sync::OnceLock; +use std::sync::atomic::{AtomicBool, Ordering}; + +use tracing::field::{Field, Visit}; +use tracing::span::{Attributes, Record}; +use tracing::{Event, Id, Level, Subscriber}; +use tracing_subscriber::Registry; +use tracing_subscriber::filter::LevelFilter; +use tracing_subscriber::layer::{Context, Layer, SubscriberExt as _}; +use tracing_subscriber::registry::LookupSpan; +use tracing_subscriber::reload; + +static RELOAD_HANDLE: OnceLock> = OnceLock::new(); +static TRACE_SPANS: AtomicBool = AtomicBool::new(false); + +/// Install the global subscriber. Idempotent: the first call wins, later +/// calls (and a foreign subscriber already being set) are no-ops. +pub fn init() { + if RELOAD_HANDLE.get().is_some() { + return; + } + let (filter, handle) = reload::Layer::::new(LevelFilter::OFF); + let subscriber = Registry::default().with(ConsoleLayer.with_filter(filter)); + if tracing::subscriber::set_global_default(subscriber).is_ok() { + let _ = RELOAD_HANDLE.set(handle); + } +} + +/// Set the live verbosity threshold. No-op until [`init`] has run. +pub fn set_level(level: LevelFilter) { + TRACE_SPANS.store(level == LevelFilter::TRACE, Ordering::Relaxed); + if let Some(handle) = RELOAD_HANDLE.get() { + let _ = handle.reload(level); + } +} + +/// Apply a host-supplied level string, installing the subscriber first so the +/// call works regardless of whether the core has been constructed yet, then +/// emitting a confirmation event so hosts can verify the logging pipeline end +/// to end. The confirmation is logged at `INFO` (mapping to `console.info`, +/// visible without DevTools "Verbose") rather than at the level just set, so it +/// surfaces even when `debug`/`trace` events land on the hidden `console.debug`. +pub fn set_level_from_str(level: &str) { + init(); + set_level(parse_level(level)); + tracing::info!(level, "log level set"); +} + +/// Parse a host-supplied level string. Unknown values disable logging. +pub fn parse_level(level: &str) -> LevelFilter { + match level.to_ascii_lowercase().as_str() { + "error" => LevelFilter::ERROR, + "warn" | "warning" => LevelFilter::WARN, + "info" => LevelFilter::INFO, + "debug" => LevelFilter::DEBUG, + "trace" => LevelFilter::TRACE, + _ => LevelFilter::OFF, + } +} + +/// Routes each event to the console method matching its level. +struct ConsoleLayer; + +impl Layer for ConsoleLayer +where + S: Subscriber, + S: for<'a> LookupSpan<'a>, +{ + fn on_new_span(&self, attrs: &Attributes<'_>, id: &Id, ctx: Context<'_, S>) { + let Some(span) = ctx.span(id) else { + return; + }; + let mut visitor = EventVisitor::default(); + attrs.record(&mut visitor); + span.extensions_mut().insert(SpanFields { + fields: visitor.fields, + }); + if trace_spans_enabled() { + emit_span("new", &span); + } + } + + fn on_record(&self, id: &Id, values: &Record<'_>, ctx: Context<'_, S>) { + let Some(span) = ctx.span(id) else { + return; + }; + let mut visitor = EventVisitor::default(); + values.record(&mut visitor); + if visitor.fields.is_empty() { + return; + } + let mut extensions = span.extensions_mut(); + if let Some(fields) = extensions.get_mut::() { + if !fields.fields.is_empty() { + fields.fields.push_str(", "); + } + fields.fields.push_str(&visitor.fields); + } else { + extensions.insert(SpanFields { + fields: visitor.fields, + }); + } + } + + fn on_close(&self, id: Id, ctx: Context<'_, S>) { + if !trace_spans_enabled() { + return; + } + let Some(span) = ctx.span(&id) else { + return; + }; + emit_span("close", &span); + } + + fn on_event(&self, event: &Event<'_>, _ctx: Context<'_, S>) { + let meta = event.metadata(); + let mut visitor = EventVisitor::default(); + event.record(&mut visitor); + + let mut line = format!("[truapi] {} {}", meta.level(), meta.target()); + if !visitor.message.is_empty() { + let _ = write!(line, ": {}", visitor.message); + } + if !visitor.fields.is_empty() { + let _ = write!(line, " {{{}}}", visitor.fields); + } + emit(*meta.level(), &line); + } +} + +#[derive(Default)] +struct SpanFields { + fields: String, +} + +fn trace_spans_enabled() -> bool { + TRACE_SPANS.load(Ordering::Relaxed) +} + +fn emit_span(kind: &str, span: &tracing_subscriber::registry::SpanRef<'_, S>) +where + S: Subscriber, + S: for<'a> LookupSpan<'a>, +{ + let meta = span.metadata(); + let mut line = format!("[truapi] TRACE {}: span {}", meta.target(), kind); + let extensions = span.extensions(); + let fields = extensions.get::(); + let _ = write!(line, " {{span={:?}", meta.name()); + if let Some(fields) = fields + && !fields.fields.is_empty() + { + let _ = write!(line, ", {}", fields.fields); + } + line.push('}'); + emit(Level::TRACE, &line); +} + +/// Collects the implicit `message` field separately from explicit key-values. +#[derive(Default)] +struct EventVisitor { + message: String, + fields: String, +} + +impl Visit for EventVisitor { + fn record_debug(&mut self, field: &Field, value: &dyn fmt::Debug) { + if field.name() == "message" { + let _ = write!(self.message, "{value:?}"); + } else { + if !self.fields.is_empty() { + self.fields.push_str(", "); + } + let _ = write!(self.fields, "{}={value:?}", field.name()); + } + } +} + +#[cfg(not(target_arch = "wasm32"))] +fn emit(_level: Level, line: &str) { + eprintln!("{line}"); +} + +#[cfg(target_arch = "wasm32")] +fn emit(level: Level, line: &str) { + let js = wasm_bindgen::JsValue::from_str(line); + match level { + Level::ERROR => web_sys::console::error_1(&js), + Level::WARN => web_sys::console::warn_1(&js), + Level::INFO => web_sys::console::info_1(&js), + Level::DEBUG | Level::TRACE => web_sys::console::debug_1(&js), + } +} diff --git a/rust/crates/truapi-server/src/runtime.rs b/rust/crates/truapi-server/src/runtime.rs new file mode 100644 index 00000000..aaa93083 --- /dev/null +++ b/rust/crates/truapi-server/src/runtime.rs @@ -0,0 +1,3744 @@ +//! `ProductRuntimeHost` adapts one product connection into the +//! typed `truapi::api::*` host traits the generated dispatcher routes to. +//! +//! Most methods are straight delegations to the platform; the rest carry +//! host-agnostic logic owned by the core (the chainHead-v1 runtime behind +//! the Chain surface, `dotns` URL parsing for `navigate_to`, and the +//! permission cache layer). Methods with no platform backing return +//! `CallError::unavailable()`. + +pub(crate) mod auth_state; +mod authority; +mod identity; +mod pairing_host; +pub(crate) mod services; +mod signing_host; +pub(crate) mod sso_pairing; +pub(crate) mod sso_remote; +pub(crate) mod statement_store; +mod statement_store_rpc; + +use core::future::Future; +use core::time::Duration; +use std::sync::Arc; + +use crate::chain_runtime::RuntimeFailure; +use crate::host_logic::dotns::{NavigateDecision, parse_navigate}; +use crate::host_logic::features::feature_supported; +use crate::host_logic::permissions::PermissionsService; +use crate::host_logic::product_account::{ + derive_product_public_key, product_public_key_to_address, +}; +use crate::host_logic::session::SessionInfo; +#[cfg(test)] +use crate::host_logic::session::SessionState; +#[cfg(test)] +use crate::subscription::Spawner; +pub(crate) use authority::ProductAuthority; +#[cfg(test)] +use pairing_host::PairingHost; +pub(crate) use pairing_host::PairingHost as PairingHostRole; +pub(crate) use services::RuntimeServices; +pub(crate) use signing_host::{LocalActivation, SigningHost as SigningHostRole}; + +use authority::{ + AuthorityCancelError, AuthorityError, AuthoritySession, CreateTransactionAuthorityRequest, + SignPayloadAuthorityRequest, SignRawAuthorityRequest, +}; + +use futures::{FutureExt, StreamExt, pin_mut}; +#[cfg(test)] +use parity_scale_codec::Encode; +use tracing::{info, instrument}; +use truapi::api::{ + Account, Chain, Chat, CoinPayment, Entropy, LocalStorage, Notifications, Payment, Permissions, + Preimage, ResourceAllocation, Signing, System, Theme, +}; +use truapi::v01; +use truapi::versioned::account::{ + HostAccountConnectionStatusSubscribeItem, HostAccountCreateProofError, + HostAccountCreateProofRequest, HostAccountCreateProofResponse, HostAccountGetAliasError, + HostAccountGetAliasRequest, HostAccountGetAliasResponse, HostAccountGetError, + HostAccountGetRequest, HostAccountGetResponse, HostGetLegacyAccountsError, + HostGetLegacyAccountsRequest, HostGetLegacyAccountsResponse, HostGetUserIdError, + HostGetUserIdRequest, HostGetUserIdResponse, HostRequestLoginError, HostRequestLoginRequest, + HostRequestLoginResponse, +}; +use truapi::versioned::chain::{ + RemoteChainHeadBodyError, RemoteChainHeadBodyRequest, RemoteChainHeadBodyResponse, + RemoteChainHeadCallError, RemoteChainHeadCallRequest, RemoteChainHeadCallResponse, + RemoteChainHeadContinueError, RemoteChainHeadContinueRequest, RemoteChainHeadContinueResponse, + RemoteChainHeadFollowItem, RemoteChainHeadFollowRequest, RemoteChainHeadHeaderError, + RemoteChainHeadHeaderRequest, RemoteChainHeadHeaderResponse, RemoteChainHeadStopOperationError, + RemoteChainHeadStopOperationRequest, RemoteChainHeadStopOperationResponse, + RemoteChainHeadStorageError, RemoteChainHeadStorageRequest, RemoteChainHeadStorageResponse, + RemoteChainHeadUnpinError, RemoteChainHeadUnpinRequest, RemoteChainHeadUnpinResponse, + RemoteChainSpecChainNameError, RemoteChainSpecChainNameRequest, + RemoteChainSpecChainNameResponse, RemoteChainSpecGenesisHashError, + RemoteChainSpecGenesisHashRequest, RemoteChainSpecGenesisHashResponse, + RemoteChainSpecPropertiesError, RemoteChainSpecPropertiesRequest, + RemoteChainSpecPropertiesResponse, RemoteChainTransactionBroadcastError, + RemoteChainTransactionBroadcastRequest, RemoteChainTransactionBroadcastResponse, + RemoteChainTransactionStopError, RemoteChainTransactionStopRequest, + RemoteChainTransactionStopResponse, +}; +use truapi::versioned::entropy::{ + HostDeriveEntropyError, HostDeriveEntropyRequest, HostDeriveEntropyResponse, +}; +use truapi::versioned::local_storage::{ + HostLocalStorageClearError, HostLocalStorageClearRequest, HostLocalStorageClearResponse, + HostLocalStorageReadError, HostLocalStorageReadRequest, HostLocalStorageReadResponse, + HostLocalStorageWriteError, HostLocalStorageWriteRequest, HostLocalStorageWriteResponse, +}; +use truapi::versioned::notifications::{ + HostPushNotificationCancelError, HostPushNotificationCancelRequest, + HostPushNotificationCancelResponse, HostPushNotificationError, HostPushNotificationRequest, + HostPushNotificationResponse, +}; +use truapi::versioned::payment::{ + HostPaymentBalanceSubscribeError, HostPaymentBalanceSubscribeItem, + HostPaymentBalanceSubscribeRequest, HostPaymentError, HostPaymentRequest, HostPaymentResponse, + HostPaymentStatusSubscribeError, HostPaymentStatusSubscribeItem, + HostPaymentStatusSubscribeRequest, HostPaymentTopUpError, HostPaymentTopUpRequest, + HostPaymentTopUpResponse, +}; +use truapi::versioned::permissions::{ + HostDevicePermissionError, HostDevicePermissionRequest, HostDevicePermissionResponse, + RemotePermissionError, RemotePermissionRequest, RemotePermissionResponse, +}; +use truapi::versioned::preimage::{ + RemotePreimageLookupSubscribeItem, RemotePreimageLookupSubscribeRequest, + RemotePreimageSubmitError, RemotePreimageSubmitRequest, RemotePreimageSubmitResponse, +}; +use truapi::versioned::resource_allocation::{ + HostRequestResourceAllocationError, HostRequestResourceAllocationRequest, + HostRequestResourceAllocationResponse, +}; +use truapi::versioned::signing::{ + HostCreateTransactionError, HostCreateTransactionRequest, HostCreateTransactionResponse, + HostCreateTransactionWithLegacyAccountError, HostCreateTransactionWithLegacyAccountRequest, + HostCreateTransactionWithLegacyAccountResponse, HostSignPayloadError, HostSignPayloadRequest, + HostSignPayloadResponse, HostSignPayloadWithLegacyAccountError, + HostSignPayloadWithLegacyAccountRequest, HostSignPayloadWithLegacyAccountResponse, + HostSignRawError, HostSignRawRequest, HostSignRawResponse, HostSignRawWithLegacyAccountError, + HostSignRawWithLegacyAccountRequest, HostSignRawWithLegacyAccountResponse, +}; +use truapi::versioned::system::{ + HostFeatureSupportedError, HostFeatureSupportedRequest, HostFeatureSupportedResponse, + HostNavigateToError, HostNavigateToRequest, HostNavigateToResponse, +}; +use truapi::versioned::theme::HostThemeSubscribeItem; +use truapi::{CallContext, CallError, CancellationReason, Subscription}; +#[cfg(test)] +use truapi_platform::Platform; +use truapi_platform::{ + AccountAliasReview, CreateTransactionReview, IdentityDisclosureReview, + PermissionAuthorizationRequest, PermissionAuthorizationStatus, PreimageSubmitReview, + ProductContext, SessionUiInfo, SignPayloadReview, SignRawReview, UserConfirmationReview, + normalize_product_identifier, +}; + +pub(super) const REMOTE_PERMISSION_DENIED_REASON: &str = "Permission denied"; +/// Host-spec B.6.2 recommends timing out unanswered SSO application requests +/// after 180 seconds: +/// +const DEFAULT_REMOTE_AUTHORITY_RESPONSE_TIMEOUT: Duration = Duration::from_secs(180); + +fn remote_authority_context(cx: &CallContext) -> CallContext { + let mut cx = cx.clone(); + if cx.timeout().is_none() { + cx.set_timeout(DEFAULT_REMOTE_AUTHORITY_RESPONSE_TIMEOUT); + } + cx +} + +async fn remote_authority_call(cx: &CallContext, call: F) -> Result +where + F: Future>, +{ + let call = call.fuse(); + let cancelled = cx.cancel().cancelled().fuse(); + pin_mut!(call, cancelled); + + if let Some(timeout_duration) = cx.timeout() { + let timeout = futures_timer::Delay::new(timeout_duration).fuse(); + pin_mut!(timeout); + futures::select! { + result = call => result, + reason = cancelled => { + let error = authority_cancellation_error(cx, reason); + let _ = call.await; + Err(error) + }, + () = timeout => { + let reason = CancellationReason::TimedOut { + timeout: timeout_duration, + }; + cx.cancel().cancel_with_reason(reason.clone()); + let error = authority_cancellation_error(cx, reason); + let _ = call.await; + Err(error) + } + } + } else { + futures::select! { + result = call => result, + reason = cancelled => { + let error = authority_cancellation_error(cx, reason); + let _ = call.await; + Err(error) + }, + } + } +} + +fn authority_cancellation_error(cx: &CallContext, reason: CancellationReason) -> AuthorityError { + AuthorityError::Cancelled(AuthorityCancelError::new(cx.request_id(), reason)) +} + +/// Product-scoped adapter that exposes a long-lived host runtime through the +/// `truapi::api::*` trait set the generated dispatcher routes to. +pub struct ProductRuntimeHost { + services: Arc, + authority: Arc, + product: ProductContext, + /// Stable per-product-runtime id used to scope long-lived chain follow + /// operation ids within one shared host runtime. + core_instance: u64, +} + +impl ProductRuntimeHost { + /// Build a product-scoped dispatcher target from a long-lived host runtime. + pub(crate) fn from_services( + services: Arc, + authority: Arc, + product: ProductContext, + ) -> Self { + let core_instance = services.next_core_instance(); + Self { + services, + authority, + product, + core_instance, + } + } + + #[cfg(test)] + pub fn new

( + platform: Arc

, + config: (truapi_platform::PairingHostConfig, ProductContext), + spawner: Spawner, + ) -> Self + where + P: Platform + 'static, + { + let (host_config, product) = config; + let platform: Arc = platform; + Self::new_pairing_for_tests(platform, host_config, product, spawner).0 + } + + /// Compatibility constructor used only by tests that do not exercise + /// product-scoped behavior. + #[cfg(test)] + fn new_compat(platform: Arc, spawner: Spawner) -> Self { + Self::new_compat_with_pairing(platform, spawner).0 + } + + #[cfg(test)] + fn new_compat_with_pairing( + platform: Arc, + spawner: Spawner, + ) -> (Self, Arc) { + let host_config = truapi_platform::PairingHostConfig::new( + truapi_platform::HostInfo { + name: "Polkadot Web".to_string(), + icon: Some("https://example.invalid/dotli.png".to_string()), + version: None, + }, + truapi_platform::PlatformInfo::default(), + [0; 32], + "polkadotapp".to_string(), + ) + .expect("compat runtime config is valid"); + Self::new_pairing_for_tests( + platform, + host_config, + ProductContext::new("unknown.dot".to_string()) + .expect("compat product context is valid"), + spawner, + ) + } + + #[cfg(test)] + fn new_pairing_for_tests( + platform: Arc, + host_config: truapi_platform::PairingHostConfig, + product: ProductContext, + spawner: Spawner, + ) -> (Self, Arc) { + let services = RuntimeServices::new( + platform.clone(), + host_config.people_chain_genesis_hash, + spawner.clone(), + ); + let pairing_host = PairingHost::new(services.clone(), host_config); + let core_instance = services.next_core_instance(); + let host = Self { + services, + authority: pairing_host.clone(), + product, + core_instance, + }; + (host, pairing_host) + } + + /// Test-only access to the shared session-state holder. + #[cfg(test)] + pub(crate) fn test_session_state(&self) -> Arc { + self.authority.session_state() + } + + /// Disconnect this runtime from its paired signing host. + #[cfg(test)] + #[instrument(skip_all, fields(runtime.method = "account.disconnect"))] + pub(crate) async fn disconnect(&self) { + self.authority.disconnect().await; + } + + fn is_product_account_valid_for_caller(&self, dot_ns_identifier: &str) -> bool { + let Ok(dot_ns_identifier) = normalize_product_identifier(dot_ns_identifier) else { + return false; + }; + let product_id = self.product_id(); + product_id.starts_with("localhost:") || dot_ns_identifier == product_id + } + + fn normalize_product_account_id( + product_account_id: v01::ProductAccountId, + ) -> Result { + Ok(v01::ProductAccountId { + dot_ns_identifier: normalize_product_identifier(&product_account_id.dot_ns_identifier) + .map_err(|_| ())?, + derivation_index: product_account_id.derivation_index, + }) + } + + fn product_id(&self) -> String { + self.product.product_id.as_str().to_string() + } + + fn legacy_slot_zero_public_key(&self, session: &AuthoritySession) -> Result<[u8; 32], String> { + derive_product_public_key(session.public_key, &self.product_id(), 0) + .map_err(|err| err.to_string()) + } + + fn product_storage_key(&self, key: String) -> String { + product_storage_key(self.product.product_id.as_str(), &key) + } + + fn follow_id(&self, id: &str) -> String { + format!("c{}:{id}", self.core_instance) + } +} + +impl ProductRuntimeHost { + /// Read a stored permission authorization status without prompting. + #[instrument(skip_all, fields(runtime.method = "permissions.authorization_status"))] + pub(crate) async fn permission_authorization_status( + &self, + request: PermissionAuthorizationRequest, + ) -> Result { + let product_id = self.product_id(); + let service = PermissionsService::new( + self.services.platform.as_ref(), + self.services.platform.as_ref(), + &product_id, + ); + service.authorization_status(&request).await + } + + /// Read stored permission authorization statuses without prompting. + #[instrument(skip_all, fields(runtime.method = "permissions.authorization_statuses"))] + pub(crate) async fn permission_authorization_statuses( + &self, + requests: Vec, + ) -> Result, v01::GenericError> { + let product_id = self.product_id(); + let service = PermissionsService::new( + self.services.platform.as_ref(), + self.services.platform.as_ref(), + &product_id, + ); + service.authorization_statuses(&requests).await + } + + /// Update a stored permission authorization status. `NotDetermined` + /// clears the stored value so the next product request prompts again. + #[instrument(skip_all, fields(runtime.method = "permissions.set_authorization_status"))] + pub(crate) async fn set_permission_authorization_status( + &self, + request: PermissionAuthorizationRequest, + status: PermissionAuthorizationStatus, + ) -> Result<(), v01::GenericError> { + let product_id = self.product_id(); + let service = PermissionsService::new( + self.services.platform.as_ref(), + self.services.platform.as_ref(), + &product_id, + ); + service.set_authorization_status(&request, status).await + } + + #[instrument(skip_all, fields(runtime.method = "permissions.remote_authorization"))] + async fn remote_permission_authorization( + &self, + permission: v01::RemotePermission, + ) -> Result { + let product_id = self.product_id(); + let service = PermissionsService::new( + self.services.platform.as_ref(), + self.services.platform.as_ref(), + &product_id, + ); + service + .check_or_prompt_remote(v01::RemotePermissionRequest { permission }) + .await + .map_err(|err| format!("permission storage failed: {err:?}")) + } + + pub(super) async fn require_remote_permission( + &self, + permission: v01::RemotePermission, + denied_error: E, + ) -> Result<(), CallError> { + match self.remote_permission_authorization(permission).await { + Ok(PermissionAuthorizationStatus::Authorized) => Ok(()), + Ok( + PermissionAuthorizationStatus::Denied + | PermissionAuthorizationStatus::NotDetermined, + ) => Err(CallError::Domain(denied_error)), + Err(reason) => Err(CallError::HostFailure { reason }), + } + } + + async fn require_chain_submit(&self, denied_error: E) -> Result<(), CallError> { + self.require_remote_permission(v01::RemotePermission::ChainSubmit, denied_error) + .await + } + + #[instrument(skip_all, fields(runtime.method = "permissions.identity_disclosure_authorization"))] + async fn identity_disclosure_authorization( + &self, + ) -> Result { + let product_id = self.product_id(); + let request = PermissionAuthorizationRequest::IdentityDisclosure; + let service = PermissionsService::new( + self.services.platform.as_ref(), + self.services.platform.as_ref(), + &product_id, + ); + let cached = service + .authorization_status(&request) + .await + .map_err(|err| format!("permission storage failed: {err:?}"))?; + if cached != PermissionAuthorizationStatus::NotDetermined { + return Ok(cached); + } + + let confirmed = self + .services + .platform + .confirm_user_action(UserConfirmationReview::IdentityDisclosure( + IdentityDisclosureReview { + product_id: product_id.clone(), + }, + )) + .await + .map_err(|err| format!("identity disclosure confirmation failed: {err:?}"))?; + let status = if confirmed { + PermissionAuthorizationStatus::Authorized + } else { + PermissionAuthorizationStatus::Denied + }; + service + .set_authorization_status(&request, status) + .await + .map_err(|err| format!("permission storage failed: {err:?}"))?; + Ok(status) + } + + fn validate_legacy_address_signer( + &self, + session: &AuthoritySession, + signer: &str, + ) -> Result<[u8; 32], v01::HostSignPayloadError> { + let public_key = self + .legacy_slot_zero_public_key(session) + .map_err(|reason| v01::HostSignPayloadError::Unknown { reason })?; + let expected = product_public_key_to_address(public_key); + if expected == signer + || parse_legacy_signer_hex(signer).is_some_and(|key| key == public_key) + { + Ok(public_key) + } else { + Err(v01::HostSignPayloadError::Unknown { + reason: "Account can't be derived from product account id".to_string(), + }) + } + } + + fn validate_legacy_public_key_signer( + &self, + session: &AuthoritySession, + signer: [u8; 32], + ) -> Result<(), v01::HostCreateTransactionError> { + let public_key = self + .legacy_slot_zero_public_key(session) + .map_err(|reason| v01::HostCreateTransactionError::Unknown { reason })?; + if public_key == signer { + Ok(()) + } else { + Err(v01::HostCreateTransactionError::Unknown { + reason: "Account can't be derived from product account id".to_string(), + }) + } + } +} + +fn parse_legacy_signer_hex(signer: &str) -> Option<[u8; 32]> { + let raw = signer + .strip_prefix("0x") + .or_else(|| signer.strip_prefix("0X")) + .unwrap_or(signer); + if raw.len() != 64 { + return None; + } + hex::decode(raw).ok()?.try_into().ok() +} + +fn product_storage_key(product_id: &str, key: &str) -> String { + format!( + "truapi:product-storage:v1:{}:{}:{}", + product_id.len(), + product_id, + key + ) +} + +fn runtime_failure_to_call_error(failure: RuntimeFailure) -> CallError { + CallError::HostFailure { + reason: failure.reason(), + } +} + +// --------------------------------------------------------------------------- +// System +// --------------------------------------------------------------------------- + +impl System for ProductRuntimeHost { + #[instrument(skip_all, fields(runtime.method = "system.feature_supported"))] + async fn feature_supported( + &self, + _cx: &CallContext, + request: HostFeatureSupportedRequest, + ) -> Result> { + let HostFeatureSupportedRequest::V1(inner) = request; + feature_supported(self.services.platform.as_ref(), inner) + .await + .map(HostFeatureSupportedResponse::V1) + .map_err(|err| CallError::Domain(HostFeatureSupportedError::V1(err))) + } + + #[instrument(skip_all, fields(runtime.method = "system.navigate_to"))] + async fn navigate_to( + &self, + _cx: &CallContext, + request: HostNavigateToRequest, + ) -> Result> { + let HostNavigateToRequest::V1(v01::HostNavigateToRequest { url }) = request; + let resolved = match parse_navigate(&url) { + NavigateDecision::Reject { reason } => { + return Err(CallError::Domain(HostNavigateToError::V1( + v01::HostNavigateToError::Unknown { reason }, + ))); + } + decision => match decision.canonical_url() { + Some(url) => url, + None => { + return Err(CallError::HostFailure { + reason: "navigate decision produced no canonical URL".to_string(), + }); + } + }, + }; + self.services + .platform + .navigate_to(resolved) + .await + .map(|()| HostNavigateToResponse::V1) + .map_err(|err| CallError::Domain(HostNavigateToError::V1(err))) + } +} + +// --------------------------------------------------------------------------- +// Permissions +// --------------------------------------------------------------------------- + +impl Permissions for ProductRuntimeHost { + #[instrument(skip_all, fields(runtime.method = "permissions.request_device_permission"))] + async fn request_device_permission( + &self, + _cx: &CallContext, + request: HostDevicePermissionRequest, + ) -> Result> { + let HostDevicePermissionRequest::V1(inner) = request; + let product_id = self.product_id(); + let service = PermissionsService::new( + self.services.platform.as_ref(), + self.services.platform.as_ref(), + &product_id, + ); + match service.check_or_prompt_device(inner).await { + Ok(decision) => Ok(HostDevicePermissionResponse::V1( + v01::HostDevicePermissionResponse { + granted: decision == PermissionAuthorizationStatus::Authorized, + }, + )), + Err(err) => Err(CallError::HostFailure { + reason: format!("permission storage failed: {err:?}"), + }), + } + } + + #[instrument(skip_all, fields(runtime.method = "permissions.request_remote_permission"))] + async fn request_remote_permission( + &self, + _cx: &CallContext, + request: RemotePermissionRequest, + ) -> Result> { + let RemotePermissionRequest::V1(inner) = request; + let product_id = self.product_id(); + let service = PermissionsService::new( + self.services.platform.as_ref(), + self.services.platform.as_ref(), + &product_id, + ); + match service.check_or_prompt_remote(inner).await { + Ok(decision) => Ok(RemotePermissionResponse::V1( + v01::RemotePermissionResponse { + granted: decision == PermissionAuthorizationStatus::Authorized, + }, + )), + Err(err) => Err(CallError::HostFailure { + reason: format!("permission storage failed: {err:?}"), + }), + } + } +} + +// --------------------------------------------------------------------------- +// LocalStorage +// --------------------------------------------------------------------------- + +impl LocalStorage for ProductRuntimeHost { + #[instrument(skip_all, fields(runtime.method = "local_storage.read"))] + async fn read( + &self, + _cx: &CallContext, + request: HostLocalStorageReadRequest, + ) -> Result> { + let HostLocalStorageReadRequest::V1(v01::HostLocalStorageReadRequest { key }) = request; + self.services + .platform + .read(self.product_storage_key(key)) + .await + .map(|value| { + HostLocalStorageReadResponse::V1(v01::HostLocalStorageReadResponse { value }) + }) + .map_err(|err| CallError::Domain(HostLocalStorageReadError::V1(err))) + } + + #[instrument(skip_all, fields(runtime.method = "local_storage.write"))] + async fn write( + &self, + _cx: &CallContext, + request: HostLocalStorageWriteRequest, + ) -> Result> { + let HostLocalStorageWriteRequest::V1(v01::HostLocalStorageWriteRequest { key, value }) = + request; + self.services + .platform + .write(self.product_storage_key(key), value) + .await + .map(|()| HostLocalStorageWriteResponse::V1) + .map_err(|err| CallError::Domain(HostLocalStorageWriteError::V1(err))) + } + + #[instrument(skip_all, fields(runtime.method = "local_storage.clear"))] + async fn clear( + &self, + _cx: &CallContext, + request: HostLocalStorageClearRequest, + ) -> Result> { + let HostLocalStorageClearRequest::V1(v01::HostLocalStorageClearRequest { key }) = request; + self.services + .platform + .clear(self.product_storage_key(key)) + .await + .map(|()| HostLocalStorageClearResponse::V1) + .map_err(|err| CallError::Domain(HostLocalStorageClearError::V1(err))) + } +} + +// --------------------------------------------------------------------------- +// Account +// --------------------------------------------------------------------------- +// +// Account-management flows live in the Rust core itself, backed by the shared +// session state and, for alias/proof/login success paths, the SSO service. + +impl Account for ProductRuntimeHost { + #[instrument(skip_all, fields(runtime.method = "account.get_account"))] + async fn get_account( + &self, + _cx: &CallContext, + request: HostAccountGetRequest, + ) -> Result> { + let HostAccountGetRequest::V1(v01::HostAccountGetRequest { product_account_id }) = request; + let product_account_id = + Self::normalize_product_account_id(product_account_id).map_err(|()| { + CallError::Domain(HostAccountGetError::V1( + v01::HostAccountGetError::DomainNotValid, + )) + })?; + + let Some(session) = self.authority.current_session() else { + return Err(CallError::Domain(HostAccountGetError::V1( + v01::HostAccountGetError::NotConnected, + ))); + }; + + let public_key = derive_product_public_key( + session.public_key, + &product_account_id.dot_ns_identifier, + product_account_id.derivation_index, + ) + .map_err(|err| { + CallError::Domain(HostAccountGetError::V1(v01::HostAccountGetError::Unknown { + reason: err.to_string(), + })) + })?; + + Ok(HostAccountGetResponse::V1(v01::HostAccountGetResponse { + account: v01::ProductAccount { + public_key: public_key.to_vec(), + }, + })) + } + + #[instrument(skip_all, fields(runtime.method = "account.get_account_alias"))] + async fn get_account_alias( + &self, + cx: &CallContext, + request: HostAccountGetAliasRequest, + ) -> Result> { + let HostAccountGetAliasRequest::V1(v01::HostAccountGetAliasRequest { product_account_id }) = + request; + let product_account_id = Self::normalize_product_account_id(product_account_id); + + let Some(session) = self.authority.current_session() else { + return Err(CallError::Domain(HostAccountGetAliasError::V1( + v01::HostAccountGetError::NotConnected, + ))); + }; + + let product_account_id = product_account_id.map_err(|()| { + CallError::Domain(HostAccountGetAliasError::V1( + v01::HostAccountGetError::DomainNotValid, + )) + })?; + + let product_id = self.product_id(); + if product_account_id.dot_ns_identifier != product_id { + let confirmed = self + .services + .platform + .confirm_user_action(UserConfirmationReview::AccountAlias(AccountAliasReview { + requesting_product_id: product_id.clone(), + target_product_id: product_account_id.dot_ns_identifier.clone(), + })) + .await + .map_err(|err| CallError::HostFailure { + reason: format!("account alias confirmation failed: {err:?}"), + })?; + if !confirmed { + return Err(CallError::Domain(HostAccountGetAliasError::V1( + v01::HostAccountGetError::Rejected, + ))); + } + } + + self.authority + .account_alias(cx, &session, product_account_id, product_id) + .await + .map(HostAccountGetAliasResponse::V1) + .map_err(|err| { + CallError::Domain(HostAccountGetAliasError::V1( + account_get_error_from_authority(err), + )) + }) + } + + #[instrument(skip_all, fields(runtime.method = "account.create_account_proof"))] + async fn create_account_proof( + &self, + _cx: &CallContext, + _request: HostAccountCreateProofRequest, + ) -> Result> { + Err(CallError::Unsupported) + } + + #[instrument(skip_all, fields(runtime.method = "account.get_legacy_accounts"))] + async fn get_legacy_accounts( + &self, + _cx: &CallContext, + _request: HostGetLegacyAccountsRequest, + ) -> Result> { + let Some(session) = self.authority.current_session() else { + return Ok(HostGetLegacyAccountsResponse::V1( + v01::HostGetLegacyAccountsResponse { accounts: vec![] }, + )); + }; + + let product_id = self.product_id(); + + let public_key = + derive_product_public_key(session.public_key, &product_id, 0).map_err(|err| { + CallError::Domain(HostGetLegacyAccountsError::V1( + v01::HostAccountGetError::Unknown { + reason: err.to_string(), + }, + )) + })?; + + Ok(HostGetLegacyAccountsResponse::V1( + v01::HostGetLegacyAccountsResponse { + accounts: vec![v01::LegacyAccount { + public_key: public_key.to_vec(), + name: session.lite_username.clone(), + }], + }, + )) + } + + #[instrument(skip_all, fields(runtime.method = "account.get_user_id"))] + async fn get_user_id( + &self, + _cx: &CallContext, + _request: HostGetUserIdRequest, + ) -> Result> { + let Some(session) = self.authority.current_session() else { + return Err(CallError::Domain(HostGetUserIdError::V1( + v01::HostGetUserIdError::NotConnected, + ))); + }; + + let primary_username = session + .full_username + .clone() + .filter(|value| !value.is_empty()) + .or_else(|| { + session + .lite_username + .clone() + .filter(|value| !value.is_empty()) + }) + .ok_or_else(|| { + CallError::Domain(HostGetUserIdError::V1(v01::HostGetUserIdError::Unknown { + reason: "No primary username for this session".to_string(), + })) + })?; + + match self.identity_disclosure_authorization().await { + Ok(PermissionAuthorizationStatus::Authorized) => {} + Ok( + PermissionAuthorizationStatus::Denied + | PermissionAuthorizationStatus::NotDetermined, + ) => { + return Err(CallError::Domain(HostGetUserIdError::V1( + v01::HostGetUserIdError::PermissionDenied, + ))); + } + Err(reason) => return Err(CallError::HostFailure { reason }), + } + + Ok(HostGetUserIdResponse::V1(v01::HostGetUserIdResponse { + primary_username, + })) + } + + #[instrument(skip_all, fields(runtime.method = "account.connection_status_subscribe"))] + async fn connection_status_subscribe( + &self, + _cx: &CallContext, + ) -> Subscription { + Subscription::new(self.authority.session_state().subscribe()) + } + + #[instrument(skip_all, fields(runtime.method = "account.request_login", product = %self.product.product_id))] + async fn request_login( + &self, + _cx: &CallContext, + _request: HostRequestLoginRequest, + ) -> Result> { + self.authority.request_login(&self.product).await + } +} + +/// Host-UI projection of an active session for `AuthState::Connected`. +fn connected_session_ui_info(session: &SessionInfo) -> SessionUiInfo { + SessionUiInfo { + public_key: session.public_key, + identity_account_id: session.identity_account_id, + lite_username: session.lite_username.clone(), + full_username: session.full_username.clone(), + } +} + +fn account_get_error_from_authority(err: AuthorityError) -> v01::HostAccountGetError { + match err { + AuthorityError::Rejected => v01::HostAccountGetError::Rejected, + AuthorityError::Disconnected => v01::HostAccountGetError::NotConnected, + AuthorityError::Cancelled(err) => v01::HostAccountGetError::Unknown { + reason: err.to_string(), + }, + AuthorityError::Unavailable { reason } | AuthorityError::Unknown { reason } => { + v01::HostAccountGetError::Unknown { reason } + } + } +} + +fn signing_call_error( + wrap: fn(v01::HostSignPayloadError) -> E, + err: AuthorityError, +) -> CallError { + CallError::Domain(wrap(match err { + AuthorityError::Rejected | AuthorityError::Disconnected => { + v01::HostSignPayloadError::Rejected + } + AuthorityError::Cancelled(err) => v01::HostSignPayloadError::Unknown { + reason: err.to_string(), + }, + AuthorityError::Unavailable { reason } | AuthorityError::Unknown { reason } => { + v01::HostSignPayloadError::Unknown { reason } + } + })) +} + +fn transaction_call_error( + wrap: fn(v01::HostCreateTransactionError) -> E, + err: AuthorityError, +) -> CallError { + CallError::Domain(wrap(match err { + AuthorityError::Rejected | AuthorityError::Disconnected => { + v01::HostCreateTransactionError::Rejected + } + AuthorityError::Cancelled(err) => v01::HostCreateTransactionError::Unknown { + reason: err.to_string(), + }, + AuthorityError::Unavailable { reason } | AuthorityError::Unknown { reason } => { + v01::HostCreateTransactionError::Unknown { reason } + } + })) +} + +impl Signing for ProductRuntimeHost { + #[instrument(skip_all, fields(runtime.method = "signing.sign_payload"))] + async fn sign_payload( + &self, + cx: &CallContext, + request: HostSignPayloadRequest, + ) -> Result> { + info!("sign_payload: requesting signing-host signature"); + let HostSignPayloadRequest::V1(mut inner) = request; + inner.account = Self::normalize_product_account_id(inner.account).map_err(|()| { + CallError::Domain(HostSignPayloadError::V1( + v01::HostSignPayloadError::PermissionDenied, + )) + })?; + if !self.is_product_account_valid_for_caller(&inner.account.dot_ns_identifier) { + return Err(CallError::Domain(HostSignPayloadError::V1( + v01::HostSignPayloadError::PermissionDenied, + ))); + } + self.require_chain_submit(HostSignPayloadError::V1( + v01::HostSignPayloadError::PermissionDenied, + )) + .await?; + let Some(session) = self.authority.current_session() else { + return Err(CallError::Domain(HostSignPayloadError::V1( + v01::HostSignPayloadError::Rejected, + ))); + }; + let confirmed = self + .services + .platform + .confirm_user_action(UserConfirmationReview::SignPayload( + SignPayloadReview::Product(inner.clone()), + )) + .await + .map_err(|err| CallError::HostFailure { + reason: format!("sign payload confirmation failed: {err:?}"), + })?; + if !confirmed { + return Err(CallError::Domain(HostSignPayloadError::V1( + v01::HostSignPayloadError::Rejected, + ))); + } + let cx = remote_authority_context(cx); + remote_authority_call( + &cx, + self.authority + .sign_payload(&cx, &session, SignPayloadAuthorityRequest::Product(inner)), + ) + .await + .map(HostSignPayloadResponse::V1) + .map_err(|reason| signing_call_error(HostSignPayloadError::V1, reason)) + } + + #[instrument(skip_all, fields(runtime.method = "signing.sign_raw"))] + async fn sign_raw( + &self, + cx: &CallContext, + request: HostSignRawRequest, + ) -> Result> { + info!("sign_raw: requesting signing-host signature"); + let HostSignRawRequest::V1(mut inner) = request; + inner.account = Self::normalize_product_account_id(inner.account).map_err(|()| { + CallError::Domain(HostSignRawError::V1( + v01::HostSignPayloadError::PermissionDenied, + )) + })?; + if !self.is_product_account_valid_for_caller(&inner.account.dot_ns_identifier) { + return Err(CallError::Domain(HostSignRawError::V1( + v01::HostSignPayloadError::PermissionDenied, + ))); + } + self.require_chain_submit(HostSignRawError::V1( + v01::HostSignPayloadError::PermissionDenied, + )) + .await?; + let Some(session) = self.authority.current_session() else { + return Err(CallError::Domain(HostSignRawError::V1( + v01::HostSignPayloadError::Rejected, + ))); + }; + let confirmed = self + .services + .platform + .confirm_user_action(UserConfirmationReview::SignRaw(SignRawReview::Product( + inner.clone(), + ))) + .await + .map_err(|err| CallError::HostFailure { + reason: format!("sign raw confirmation failed: {err:?}"), + })?; + if !confirmed { + return Err(CallError::Domain(HostSignRawError::V1( + v01::HostSignPayloadError::Rejected, + ))); + } + let cx = remote_authority_context(cx); + remote_authority_call( + &cx, + self.authority + .sign_raw(&cx, &session, SignRawAuthorityRequest::Product(inner)), + ) + .await + .map(HostSignRawResponse::V1) + .map_err(|reason| signing_call_error(HostSignRawError::V1, reason)) + } + + #[instrument(skip_all, fields(runtime.method = "signing.create_transaction"))] + async fn create_transaction( + &self, + cx: &CallContext, + request: HostCreateTransactionRequest, + ) -> Result> { + info!("create_transaction: requesting signing-host signature"); + let HostCreateTransactionRequest::V1(mut inner) = request; + inner.signer = Self::normalize_product_account_id(inner.signer).map_err(|()| { + CallError::Domain(HostCreateTransactionError::V1( + v01::HostCreateTransactionError::PermissionDenied, + )) + })?; + if !self.is_product_account_valid_for_caller(&inner.signer.dot_ns_identifier) { + return Err(CallError::Domain(HostCreateTransactionError::V1( + v01::HostCreateTransactionError::PermissionDenied, + ))); + } + self.require_chain_submit(HostCreateTransactionError::V1( + v01::HostCreateTransactionError::PermissionDenied, + )) + .await?; + let Some(session) = self.authority.current_session() else { + return Err(CallError::Domain(HostCreateTransactionError::V1( + v01::HostCreateTransactionError::Rejected, + ))); + }; + let confirmed = self + .services + .platform + .confirm_user_action(UserConfirmationReview::CreateTransaction( + CreateTransactionReview::Product(inner.clone()), + )) + .await + .map_err(|err| CallError::HostFailure { + reason: format!("create transaction confirmation failed: {err:?}"), + })?; + if !confirmed { + return Err(CallError::Domain(HostCreateTransactionError::V1( + v01::HostCreateTransactionError::Rejected, + ))); + } + let cx = remote_authority_context(cx); + remote_authority_call( + &cx, + self.authority.create_transaction( + &cx, + &session, + CreateTransactionAuthorityRequest::Product(inner), + ), + ) + .await + .map(HostCreateTransactionResponse::V1) + .map_err(|reason| transaction_call_error(HostCreateTransactionError::V1, reason)) + } + + #[instrument(skip_all, fields(runtime.method = "signing.sign_payload_with_legacy_account"))] + async fn sign_payload_with_legacy_account( + &self, + cx: &CallContext, + request: HostSignPayloadWithLegacyAccountRequest, + ) -> Result< + HostSignPayloadWithLegacyAccountResponse, + CallError, + > { + let HostSignPayloadWithLegacyAccountRequest::V1(inner) = request; + let Some(session) = self.authority.current_session() else { + return Err(CallError::Domain( + HostSignPayloadWithLegacyAccountError::V1(v01::HostSignPayloadError::Rejected), + )); + }; + self.validate_legacy_address_signer(&session, &inner.signer) + .map_err(|err| CallError::Domain(HostSignPayloadWithLegacyAccountError::V1(err)))?; + self.require_chain_submit(HostSignPayloadWithLegacyAccountError::V1( + v01::HostSignPayloadError::PermissionDenied, + )) + .await?; + let confirmed = self + .services + .platform + .confirm_user_action(UserConfirmationReview::SignPayload( + SignPayloadReview::LegacyAccount(inner.clone()), + )) + .await + .map_err(|err| CallError::HostFailure { + reason: format!("sign payload confirmation failed: {err:?}"), + })?; + if !confirmed { + return Err(CallError::Domain( + HostSignPayloadWithLegacyAccountError::V1(v01::HostSignPayloadError::Rejected), + )); + } + let cx = remote_authority_context(cx); + remote_authority_call( + &cx, + self.authority.sign_payload( + &cx, + &session, + SignPayloadAuthorityRequest::LegacyAccount { + product_account: v01::ProductAccountId { + dot_ns_identifier: self.product_id(), + derivation_index: 0, + }, + request: inner, + }, + ), + ) + .await + .map(HostSignPayloadWithLegacyAccountResponse::V1) + .map_err(|reason| signing_call_error(HostSignPayloadWithLegacyAccountError::V1, reason)) + } + + #[instrument(skip_all, fields(runtime.method = "signing.sign_raw_with_legacy_account"))] + async fn sign_raw_with_legacy_account( + &self, + cx: &CallContext, + request: HostSignRawWithLegacyAccountRequest, + ) -> Result> + { + let HostSignRawWithLegacyAccountRequest::V1(inner) = request; + let Some(session) = self.authority.current_session() else { + return Err(CallError::Domain(HostSignRawWithLegacyAccountError::V1( + v01::HostSignPayloadError::Rejected, + ))); + }; + self.validate_legacy_address_signer(&session, &inner.signer) + .map_err(|err| CallError::Domain(HostSignRawWithLegacyAccountError::V1(err)))?; + self.require_chain_submit(HostSignRawWithLegacyAccountError::V1( + v01::HostSignPayloadError::PermissionDenied, + )) + .await?; + let confirmed = self + .services + .platform + .confirm_user_action(UserConfirmationReview::SignRaw( + SignRawReview::LegacyAccount(inner.clone()), + )) + .await + .map_err(|err| CallError::HostFailure { + reason: format!("sign raw confirmation failed: {err:?}"), + })?; + if !confirmed { + return Err(CallError::Domain(HostSignRawWithLegacyAccountError::V1( + v01::HostSignPayloadError::Rejected, + ))); + } + let cx = remote_authority_context(cx); + remote_authority_call( + &cx, + self.authority.sign_raw( + &cx, + &session, + SignRawAuthorityRequest::LegacyAccount { + product_account: v01::ProductAccountId { + dot_ns_identifier: self.product_id(), + derivation_index: 0, + }, + request: inner, + }, + ), + ) + .await + .map(HostSignRawWithLegacyAccountResponse::V1) + .map_err(|reason| signing_call_error(HostSignRawWithLegacyAccountError::V1, reason)) + } + + #[instrument(skip_all, fields(runtime.method = "signing.create_transaction_with_legacy_account"))] + async fn create_transaction_with_legacy_account( + &self, + cx: &CallContext, + request: HostCreateTransactionWithLegacyAccountRequest, + ) -> Result< + HostCreateTransactionWithLegacyAccountResponse, + CallError, + > { + let HostCreateTransactionWithLegacyAccountRequest::V1(inner) = request; + let Some(session) = self.authority.current_session() else { + return Err(CallError::Domain( + HostCreateTransactionWithLegacyAccountError::V1( + v01::HostCreateTransactionError::Rejected, + ), + )); + }; + self.validate_legacy_public_key_signer(&session, inner.signer) + .map_err(|err| { + CallError::Domain(HostCreateTransactionWithLegacyAccountError::V1(err)) + })?; + self.require_chain_submit(HostCreateTransactionWithLegacyAccountError::V1( + v01::HostCreateTransactionError::PermissionDenied, + )) + .await?; + let confirmed = self + .services + .platform + .confirm_user_action(UserConfirmationReview::CreateTransaction( + CreateTransactionReview::LegacyAccount(inner.clone()), + )) + .await + .map_err(|err| CallError::HostFailure { + reason: format!("create transaction confirmation failed: {err:?}"), + })?; + if !confirmed { + return Err(CallError::Domain( + HostCreateTransactionWithLegacyAccountError::V1( + v01::HostCreateTransactionError::Rejected, + ), + )); + } + let cx = remote_authority_context(cx); + remote_authority_call( + &cx, + self.authority.create_transaction( + &cx, + &session, + CreateTransactionAuthorityRequest::LegacyAccount { + product_account: v01::ProductAccountId { + dot_ns_identifier: self.product_id(), + derivation_index: 0, + }, + request: inner, + }, + ), + ) + .await + .map(|response| { + HostCreateTransactionWithLegacyAccountResponse::V1( + v01::HostCreateTransactionWithLegacyAccountResponse { + transaction: response.transaction, + }, + ) + }) + .map_err(|reason| { + transaction_call_error(HostCreateTransactionWithLegacyAccountError::V1, reason) + }) + } +} + +// --------------------------------------------------------------------------- +// Chain +// --------------------------------------------------------------------------- +// +// The chain surface is backed by `ChainRuntime`, which keeps one +// `chainHead_v1` connection per genesis hash on top of the platform-supplied +// `ChainProvider::connect`. Requests go through `request_value` and parse +// json-rpc responses into typed v01 results; follow notifications are +// translated into `RemoteChainHeadFollowItem` items on the subscription +// stream. + +impl Chain for ProductRuntimeHost { + #[instrument(skip_all, fields(runtime.method = "chain.follow_head_subscribe"))] + async fn follow_head_subscribe( + &self, + cx: &CallContext, + request: RemoteChainHeadFollowRequest, + ) -> Subscription { + let RemoteChainHeadFollowRequest::V1(inner) = request; + let follow_subscription_id = self.follow_id(cx.request_id()); + let stream = self + .services + .chain + .remote_chain_head_follow(follow_subscription_id, inner) + .map(RemoteChainHeadFollowItem::V1); + Subscription::new(Box::pin(stream)) + } + + #[instrument(skip_all, fields(runtime.method = "chain.get_head_header"))] + async fn get_head_header( + &self, + _cx: &CallContext, + request: RemoteChainHeadHeaderRequest, + ) -> Result> { + let RemoteChainHeadHeaderRequest::V1(mut inner) = request; + inner.follow_subscription_id = self.follow_id(&inner.follow_subscription_id); + self.services + .chain + .remote_chain_head_header(inner) + .await + .map(RemoteChainHeadHeaderResponse::V1) + .map_err(runtime_failure_to_call_error) + } + + #[instrument(skip_all, fields(runtime.method = "chain.get_head_body"))] + async fn get_head_body( + &self, + _cx: &CallContext, + request: RemoteChainHeadBodyRequest, + ) -> Result> { + let RemoteChainHeadBodyRequest::V1(mut inner) = request; + inner.follow_subscription_id = self.follow_id(&inner.follow_subscription_id); + self.services + .chain + .remote_chain_head_body(inner) + .await + .map(RemoteChainHeadBodyResponse::V1) + .map_err(runtime_failure_to_call_error) + } + + #[instrument(skip_all, fields(runtime.method = "chain.get_head_storage"))] + async fn get_head_storage( + &self, + _cx: &CallContext, + request: RemoteChainHeadStorageRequest, + ) -> Result> { + let RemoteChainHeadStorageRequest::V1(mut inner) = request; + inner.follow_subscription_id = self.follow_id(&inner.follow_subscription_id); + self.services + .chain + .remote_chain_head_storage(inner) + .await + .map(RemoteChainHeadStorageResponse::V1) + .map_err(runtime_failure_to_call_error) + } + + #[instrument(skip_all, fields(runtime.method = "chain.call_head"))] + async fn call_head( + &self, + _cx: &CallContext, + request: RemoteChainHeadCallRequest, + ) -> Result> { + let RemoteChainHeadCallRequest::V1(mut inner) = request; + inner.follow_subscription_id = self.follow_id(&inner.follow_subscription_id); + self.services + .chain + .remote_chain_head_call(inner) + .await + .map(RemoteChainHeadCallResponse::V1) + .map_err(runtime_failure_to_call_error) + } + + #[instrument(skip_all, fields(runtime.method = "chain.unpin_head"))] + async fn unpin_head( + &self, + _cx: &CallContext, + request: RemoteChainHeadUnpinRequest, + ) -> Result> { + let RemoteChainHeadUnpinRequest::V1(mut inner) = request; + inner.follow_subscription_id = self.follow_id(&inner.follow_subscription_id); + self.services + .chain + .remote_chain_head_unpin(inner) + .await + .map(|()| RemoteChainHeadUnpinResponse::V1) + .map_err(runtime_failure_to_call_error) + } + + #[instrument(skip_all, fields(runtime.method = "chain.continue_head"))] + async fn continue_head( + &self, + _cx: &CallContext, + request: RemoteChainHeadContinueRequest, + ) -> Result> { + let RemoteChainHeadContinueRequest::V1(mut inner) = request; + inner.follow_subscription_id = self.follow_id(&inner.follow_subscription_id); + self.services + .chain + .remote_chain_head_continue(inner) + .await + .map(|()| RemoteChainHeadContinueResponse::V1) + .map_err(runtime_failure_to_call_error) + } + + #[instrument(skip_all, fields(runtime.method = "chain.stop_head_operation"))] + async fn stop_head_operation( + &self, + _cx: &CallContext, + request: RemoteChainHeadStopOperationRequest, + ) -> Result> + { + let RemoteChainHeadStopOperationRequest::V1(mut inner) = request; + inner.follow_subscription_id = self.follow_id(&inner.follow_subscription_id); + self.services + .chain + .remote_chain_head_stop_operation(inner) + .await + .map(|()| RemoteChainHeadStopOperationResponse::V1) + .map_err(runtime_failure_to_call_error) + } + + #[instrument(skip_all, fields(runtime.method = "chain.get_spec_genesis_hash"))] + async fn get_spec_genesis_hash( + &self, + _cx: &CallContext, + request: RemoteChainSpecGenesisHashRequest, + ) -> Result> + { + let RemoteChainSpecGenesisHashRequest::V1(inner) = request; + self.services + .chain + .remote_chain_spec_genesis_hash(inner.genesis_hash) + .await + .map(RemoteChainSpecGenesisHashResponse::V1) + .map_err(runtime_failure_to_call_error) + } + + #[instrument(skip_all, fields(runtime.method = "chain.get_spec_chain_name"))] + async fn get_spec_chain_name( + &self, + _cx: &CallContext, + request: RemoteChainSpecChainNameRequest, + ) -> Result> { + let RemoteChainSpecChainNameRequest::V1(inner) = request; + self.services + .chain + .remote_chain_spec_chain_name(inner.genesis_hash) + .await + .map(RemoteChainSpecChainNameResponse::V1) + .map_err(runtime_failure_to_call_error) + } + + #[instrument(skip_all, fields(runtime.method = "chain.get_spec_properties"))] + async fn get_spec_properties( + &self, + _cx: &CallContext, + request: RemoteChainSpecPropertiesRequest, + ) -> Result> { + let RemoteChainSpecPropertiesRequest::V1(inner) = request; + self.services + .chain + .remote_chain_spec_properties(inner.genesis_hash) + .await + .map(RemoteChainSpecPropertiesResponse::V1) + .map_err(runtime_failure_to_call_error) + } + + #[instrument(skip_all, fields(runtime.method = "chain.broadcast_transaction"))] + async fn broadcast_transaction( + &self, + _cx: &CallContext, + request: RemoteChainTransactionBroadcastRequest, + ) -> Result< + RemoteChainTransactionBroadcastResponse, + CallError, + > { + let RemoteChainTransactionBroadcastRequest::V1(inner) = request; + self.require_chain_submit(RemoteChainTransactionBroadcastError::V1( + v01::GenericError { + reason: REMOTE_PERMISSION_DENIED_REASON.to_string(), + }, + )) + .await?; + self.services + .chain + .remote_chain_transaction_broadcast(inner) + .await + .map(RemoteChainTransactionBroadcastResponse::V1) + .map_err(runtime_failure_to_call_error) + } + + #[instrument(skip_all, fields(runtime.method = "chain.stop_transaction"))] + async fn stop_transaction( + &self, + _cx: &CallContext, + request: RemoteChainTransactionStopRequest, + ) -> Result> + { + let RemoteChainTransactionStopRequest::V1(inner) = request; + self.services + .chain + .remote_chain_transaction_stop(inner) + .await + .map(|()| RemoteChainTransactionStopResponse::V1) + .map_err(runtime_failure_to_call_error) + } +} + +// --------------------------------------------------------------------------- +// Deferred product surfaces. +// +// Payment and full account proof are explicitly out of current dotli parity, +// but products should still observe dotli's typed "not implemented" errors +// rather than a generic transport failure. +// Chat and CoinPayment remain outside this milestone and keep their generated +// trait defaults until another host/product needs real implementations. + +const PAYMENTS_NOT_IMPLEMENTED: &str = "Payments are not supported in dot.li"; + +impl Chat for ProductRuntimeHost {} +impl CoinPayment for ProductRuntimeHost {} +impl Payment for ProductRuntimeHost { + #[instrument(skip_all, fields(runtime.method = "payment.balance_subscribe"))] + async fn balance_subscribe( + &self, + _cx: &CallContext, + _request: HostPaymentBalanceSubscribeRequest, + ) -> Result< + Subscription, + CallError, + > { + Err(CallError::Domain(HostPaymentBalanceSubscribeError::V1( + v01::HostPaymentBalanceSubscribeError::PermissionDenied, + ))) + } + + #[instrument(skip_all, fields(runtime.method = "payment.request"))] + async fn request( + &self, + _cx: &CallContext, + _request: HostPaymentRequest, + ) -> Result> { + Err(CallError::Domain(HostPaymentError::V1( + v01::HostPaymentError::Unknown { + reason: PAYMENTS_NOT_IMPLEMENTED.to_string(), + }, + ))) + } + + #[instrument(skip_all, fields(runtime.method = "payment.status_subscribe"))] + async fn status_subscribe( + &self, + _cx: &CallContext, + _request: HostPaymentStatusSubscribeRequest, + ) -> Result< + Subscription, + CallError, + > { + Err(CallError::Domain(HostPaymentStatusSubscribeError::V1( + v01::HostPaymentStatusSubscribeError::Unknown { + reason: PAYMENTS_NOT_IMPLEMENTED.to_string(), + }, + ))) + } + + #[instrument(skip_all, fields(runtime.method = "payment.top_up"))] + async fn top_up( + &self, + _cx: &CallContext, + _request: HostPaymentTopUpRequest, + ) -> Result> { + Err(CallError::Domain(HostPaymentTopUpError::V1( + v01::HostPaymentTopUpError::Unknown { + reason: PAYMENTS_NOT_IMPLEMENTED.to_string(), + }, + ))) + } +} + +impl ResourceAllocation for ProductRuntimeHost { + #[instrument(skip_all, fields(runtime.method = "resource_allocation.request"))] + async fn request( + &self, + cx: &CallContext, + request: HostRequestResourceAllocationRequest, + ) -> Result> + { + let HostRequestResourceAllocationRequest::V1(inner) = request; + let Some(session) = self.authority.current_session() else { + return Err(CallError::Domain(HostRequestResourceAllocationError::V1( + v01::ResourceAllocationError::Unknown { + reason: "No active session".to_string(), + }, + ))); + }; + + let confirmed = self + .services + .platform + .confirm_user_action(UserConfirmationReview::ResourceAllocation(inner.clone())) + .await + .map_err(|err| CallError::HostFailure { + reason: format!("resource allocation confirmation failed: {err:?}"), + })?; + if !confirmed { + return Err(CallError::Domain(HostRequestResourceAllocationError::V1( + v01::ResourceAllocationError::Unknown { + reason: "User rejected resource allocation".to_string(), + }, + ))); + } + let cx = remote_authority_context(cx); + remote_authority_call( + &cx, + self.authority + .allocate_resources(&cx, &session, self.product_id(), inner), + ) + .await + .map(HostRequestResourceAllocationResponse::V1) + .map_err(|err| { + CallError::Domain(HostRequestResourceAllocationError::V1( + v01::ResourceAllocationError::Unknown { + reason: err.reason(), + }, + )) + }) + } +} +// --------------------------------------------------------------------------- +// Entropy +// --------------------------------------------------------------------------- + +impl Entropy for ProductRuntimeHost { + #[instrument(skip_all, fields(runtime.method = "entropy.derive"))] + async fn derive( + &self, + _cx: &CallContext, + request: HostDeriveEntropyRequest, + ) -> Result> { + let HostDeriveEntropyRequest::V1(v01::HostDeriveEntropyRequest { context }) = request; + let Some(session) = self.authority.current_session() else { + return Err(CallError::Domain(HostDeriveEntropyError::V1( + v01::HostDeriveEntropyError::Unknown { + reason: "Not connected".to_string(), + }, + ))); + }; + let entropy = self + .authority + .derive_entropy(&session, &self.product_id(), &context) + .map_err(|err| { + CallError::Domain(HostDeriveEntropyError::V1( + v01::HostDeriveEntropyError::Unknown { + reason: err.reason(), + }, + )) + })?; + + Ok(HostDeriveEntropyResponse::V1( + v01::HostDeriveEntropyResponse { entropy }, + )) + } +} + +// --------------------------------------------------------------------------- +// Preimage +// --------------------------------------------------------------------------- + +impl Preimage for ProductRuntimeHost { + #[instrument(skip_all, fields(runtime.method = "preimage.lookup_subscribe"))] + async fn lookup_subscribe( + &self, + _cx: &CallContext, + request: RemotePreimageLookupSubscribeRequest, + ) -> Subscription { + let RemotePreimageLookupSubscribeRequest::V1(v01::RemotePreimageLookupSubscribeRequest { + key, + }) = request; + let stream = self + .services + .platform + .lookup_preimage(key) + .filter_map(|item| async move { + item.ok().map(|value| { + RemotePreimageLookupSubscribeItem::V1(v01::RemotePreimageLookupSubscribeItem { + value, + }) + }) + }); + Subscription::new(Box::pin(stream)) + } + + #[instrument(skip_all, fields(runtime.method = "preimage.submit"))] + async fn submit( + &self, + _cx: &CallContext, + request: RemotePreimageSubmitRequest, + ) -> Result> { + let RemotePreimageSubmitRequest::V1(value) = request; + self.require_remote_permission( + v01::RemotePermission::PreimageSubmit, + RemotePreimageSubmitError::V1(v01::PreimageSubmitError::Unknown { + reason: REMOTE_PERMISSION_DENIED_REASON.to_string(), + }), + ) + .await?; + let confirmed = self + .services + .platform + .confirm_user_action(UserConfirmationReview::PreimageSubmit( + PreimageSubmitReview { + size: value.len() as u64, + }, + )) + .await + .map_err(|err| { + CallError::Domain(RemotePreimageSubmitError::V1( + v01::PreimageSubmitError::Unknown { reason: err.reason }, + )) + })?; + if !confirmed { + return Err(CallError::Domain(RemotePreimageSubmitError::V1( + v01::PreimageSubmitError::Unknown { + reason: "User rejected preimage submission".to_string(), + }, + ))); + } + self.services + .platform + .submit_preimage(value) + .await + .map(RemotePreimageSubmitResponse::V1) + .map_err(|err| CallError::Domain(RemotePreimageSubmitError::V1(err))) + } +} + +// --------------------------------------------------------------------------- +// Theme +// --------------------------------------------------------------------------- + +impl Theme for ProductRuntimeHost { + #[instrument(skip_all, fields(runtime.method = "theme.subscribe"))] + async fn subscribe(&self, _cx: &CallContext) -> Subscription { + let stream = self + .services + .platform + .subscribe_theme() + .filter_map(|item| async { + item.ok().map(|variant| { + HostThemeSubscribeItem::V1(v01::HostThemeSubscribeItem { + name: v01::ThemeName::Default, + variant, + }) + }) + }); + Subscription::new(Box::pin(stream)) + } +} + +// `Notifications` delegates to the platform so hosts can own scheduling and +// cancellation while the core preserves the typed TrUAPI wire shape. +impl Notifications for ProductRuntimeHost { + #[instrument(skip_all, fields(runtime.method = "notifications.send_push_notification"))] + async fn send_push_notification( + &self, + _cx: &CallContext, + request: HostPushNotificationRequest, + ) -> Result> { + let HostPushNotificationRequest::V1(inner) = request; + self.services + .platform + .push_notification(inner) + .await + .map(HostPushNotificationResponse::V1) + .map_err(|err| { + CallError::Domain(HostPushNotificationError::V1( + v01::HostPushNotificationError::Unknown { reason: err.reason }, + )) + }) + } + + #[instrument(skip_all, fields(runtime.method = "notifications.cancel_push_notification"))] + async fn cancel_push_notification( + &self, + _cx: &CallContext, + request: HostPushNotificationCancelRequest, + ) -> Result> + { + let HostPushNotificationCancelRequest::V1(v01::HostPushNotificationCancelRequest { id }) = + request; + self.services + .platform + .cancel_notification(id) + .await + .map(|()| HostPushNotificationCancelResponse::V1) + .map_err(|err| { + CallError::Domain(HostPushNotificationCancelError::V1(v01::GenericError { + reason: err.reason, + })) + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::host_logic::sso::messages::{RemoteMessageData, v1}; + use crate::test_support::*; + use std::sync::Mutex; + use std::sync::atomic::Ordering; + use truapi_platform::{AuthState, CoreStorageKey, PermissionAuthorizationRequest}; + + fn wait_until(mut condition: impl FnMut() -> bool, message: &str) { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); + while !condition() { + assert!(std::time::Instant::now() < deadline, "{message}"); + std::thread::sleep(std::time::Duration::from_millis(5)); + } + } + + fn recorded_rpc_methods(sent_rpc: &Mutex>) -> Vec { + sent_rpc + .lock() + .expect("rpc list mutex poisoned") + .iter() + .map(|request| { + serde_json::from_str::(request).unwrap()["method"] + .as_str() + .unwrap() + .to_string() + }) + .collect() + } + + fn recorded_rpc_method_count(sent_rpc: &Mutex>, method: &str) -> usize { + recorded_rpc_methods(sent_rpc) + .iter() + .filter(|candidate| candidate.as_str() == method) + .count() + } + + #[test] + fn feature_supported_round_trips_through_runtime() { + let host = ProductRuntimeHost::new_compat(stub_platform(), test_spawner()); + let cx = CallContext::new(); + let request = HostFeatureSupportedRequest::V1(v01::HostFeatureSupportedRequest::Chain { + genesis_hash: vec![0u8; 32], + }); + let response = futures::executor::block_on(host.feature_supported(&cx, request)).unwrap(); + let HostFeatureSupportedResponse::V1(inner) = response; + assert!(inner.supported); + } + + #[test] + fn chain_follow_ids_are_scoped_per_product_core() { + let (host_config, product) = runtime_config("same.dot"); + let spawner = test_spawner(); + let platform: Arc = stub_platform(); + let services = RuntimeServices::new( + platform.clone(), + host_config.people_chain_genesis_hash, + spawner.clone(), + ); + let pairing_host = PairingHost::new(services.clone(), host_config); + let first = ProductRuntimeHost::from_services( + services.clone(), + pairing_host.clone(), + product.clone(), + ); + let second = ProductRuntimeHost::from_services(services, pairing_host, product); + + assert_eq!(first.follow_id("request-1"), "c1:request-1"); + assert_eq!(second.follow_id("request-1"), "c2:request-1"); + } + + #[test] + fn navigate_to_uses_dotns_decision_and_then_platform() { + let host = ProductRuntimeHost::new_compat(stub_platform(), test_spawner()); + let cx = CallContext::new(); + let request = HostNavigateToRequest::V1(v01::HostNavigateToRequest { + url: "mytestapp.dot".to_string(), + }); + let response = futures::executor::block_on(host.navigate_to(&cx, request)).unwrap(); + assert_eq!(response, HostNavigateToResponse::V1); + } + + #[test] + fn navigate_to_rejects_empty_input_without_calling_platform() { + let host = ProductRuntimeHost::new_compat(stub_platform(), test_spawner()); + let cx = CallContext::new(); + let request = HostNavigateToRequest::V1(v01::HostNavigateToRequest { + url: "".to_string(), + }); + let err = futures::executor::block_on(host.navigate_to(&cx, request)).unwrap_err(); + match err { + CallError::Domain(HostNavigateToError::V1(v01::HostNavigateToError::Unknown { + .. + })) => {} + other => panic!("expected Unknown navigate error, got {other:?}"), + } + } + + #[test] + fn push_notification_delegates_payload_and_returns_host_id() { + let pushed_notifications = Arc::new(Mutex::new(Vec::new())); + let platform = Arc::new(StubPlatform { + notification_id: 42, + pushed_notifications: pushed_notifications.clone(), + ..Default::default() + }); + let host = ProductRuntimeHost::new_compat(platform, test_spawner()); + let cx = CallContext::new(); + let request = HostPushNotificationRequest::V1(v01::HostPushNotificationRequest { + text: "Hello".to_string(), + deeplink: Some("https://example.invalid/launch".to_string()), + scheduled_at: Some(1_776_144_000_000), + }); + + let response = + futures::executor::block_on(host.send_push_notification(&cx, request)).unwrap(); + + assert_eq!( + response, + HostPushNotificationResponse::V1(v01::HostPushNotificationResponse { id: 42 }) + ); + assert_eq!( + pushed_notifications + .lock() + .expect("notification list mutex poisoned") + .as_slice(), + &[v01::HostPushNotificationRequest { + text: "Hello".to_string(), + deeplink: Some("https://example.invalid/launch".to_string()), + scheduled_at: Some(1_776_144_000_000), + }] + ); + } + + #[test] + fn cancel_notification_delegates_host_id() { + let cancelled_notifications = Arc::new(Mutex::new(Vec::new())); + let platform = Arc::new(StubPlatform { + cancelled_notifications: cancelled_notifications.clone(), + ..Default::default() + }); + let host = ProductRuntimeHost::new_compat(platform, test_spawner()); + let cx = CallContext::new(); + let request = + HostPushNotificationCancelRequest::V1(v01::HostPushNotificationCancelRequest { + id: 42, + }); + + let response = + futures::executor::block_on(host.cancel_push_notification(&cx, request)).unwrap(); + + assert_eq!(response, HostPushNotificationCancelResponse::V1); + assert_eq!( + cancelled_notifications + .lock() + .expect("notification cancellation list mutex poisoned") + .as_slice(), + &[42] + ); + } + + #[test] + fn get_account_requires_session() { + let host = ProductRuntimeHost::new_compat(stub_platform(), test_spawner()); + let cx = CallContext::new(); + let request = HostAccountGetRequest::V1(v01::HostAccountGetRequest { + product_account_id: v01::ProductAccountId { + dot_ns_identifier: "myapp.dot".to_string(), + derivation_index: 0, + }, + }); + let err = futures::executor::block_on(host.get_account(&cx, request)).unwrap_err(); + assert!(matches!( + err, + CallError::Domain(HostAccountGetError::V1( + v01::HostAccountGetError::NotConnected + )) + )); + } + + #[test] + fn get_account_rejects_invalid_product_identifier() { + let host = ProductRuntimeHost::new_compat(stub_platform(), test_spawner()); + host.test_session_state().set_session(session_info()); + let cx = CallContext::new(); + let request = HostAccountGetRequest::V1(v01::HostAccountGetRequest { + product_account_id: v01::ProductAccountId { + dot_ns_identifier: "example.com".to_string(), + derivation_index: 0, + }, + }); + let err = futures::executor::block_on(host.get_account(&cx, request)).unwrap_err(); + assert!(matches!( + err, + CallError::Domain(HostAccountGetError::V1( + v01::HostAccountGetError::DomainNotValid + )) + )); + } + + #[test] + fn get_account_derives_dotli_product_key() { + let host = ProductRuntimeHost::new_compat(stub_platform(), test_spawner()); + host.test_session_state().set_session(session_info()); + let cx = CallContext::new(); + let request = HostAccountGetRequest::V1(v01::HostAccountGetRequest { + product_account_id: v01::ProductAccountId { + dot_ns_identifier: "myapp.dot".to_string(), + derivation_index: 0, + }, + }); + let response = futures::executor::block_on(host.get_account(&cx, request)).unwrap(); + let HostAccountGetResponse::V1(inner) = response; + assert_eq!( + hex::encode(inner.account.public_key), + "281489e3dd1c4dbe88cd670a59edcc9c44d64f510d302bd527ec306f10292f08" + ); + } + + #[test] + fn get_account_normalizes_product_identifier_before_deriving() { + let host = ProductRuntimeHost::new_compat(stub_platform(), test_spawner()); + host.test_session_state().set_session(session_info()); + let cx = CallContext::new(); + let request = HostAccountGetRequest::V1(v01::HostAccountGetRequest { + product_account_id: v01::ProductAccountId { + dot_ns_identifier: "MyApp.DOT".to_string(), + derivation_index: 0, + }, + }); + let response = futures::executor::block_on(host.get_account(&cx, request)).unwrap(); + let HostAccountGetResponse::V1(inner) = response; + assert_eq!( + hex::encode(inner.account.public_key), + "281489e3dd1c4dbe88cd670a59edcc9c44d64f510d302bd527ec306f10292f08" + ); + } + + #[test] + fn get_account_alias_requires_session() { + let host = + ProductRuntimeHost::new(stub_platform(), runtime_config("myapp.dot"), test_spawner()); + let cx = CallContext::new(); + let err = futures::executor::block_on( + host.get_account_alias(&cx, account_alias_request("myapp.dot")), + ) + .unwrap_err(); + assert!(matches!( + err, + CallError::Domain(HostAccountGetAliasError::V1( + v01::HostAccountGetError::NotConnected + )) + )); + } + + #[test] + fn get_account_alias_rejects_invalid_product_identifier() { + let host = + ProductRuntimeHost::new(stub_platform(), runtime_config("myapp.dot"), test_spawner()); + let mut session = sso_session_info(); + session.root_entropy_source = session_info().root_entropy_source; + host.test_session_state().set_session(session); + let cx = CallContext::new(); + let err = futures::executor::block_on( + host.get_account_alias(&cx, account_alias_request("example.com")), + ) + .unwrap_err(); + assert!(matches!( + err, + CallError::Domain(HostAccountGetAliasError::V1( + v01::HostAccountGetError::DomainNotValid + )) + )); + } + + #[test] + fn get_account_alias_same_domain_returns_sso_response() { + let session = sso_session_info(); + let platform = Arc::new(StubPlatform { + rpc_responses: sso_success_responses( + &session, + "alias-1", + crate::host_logic::sso::messages::RemoteMessage { + message_id: "wallet-alias-1".to_string(), + data: crate::host_logic::sso::messages::RemoteMessageData::V1( + crate::host_logic::sso::messages::v1::RemoteMessage::RingVrfAliasResponse( + crate::host_logic::sso::messages::RingVrfAliasResponse { + responding_to: "alias-1".to_string(), + payload: Ok(v01::HostAccountGetAliasResponse { + context: [9; 32], + alias: vec![1, 2, 3], + }), + }, + ), + ), + }, + ), + ..Default::default() + }); + let host = ProductRuntimeHost::new( + platform.clone(), + runtime_config("myapp.dot"), + test_spawner(), + ); + host.test_session_state().set_session(session.clone()); + let cx = CallContext::with_request_id("alias-1".to_string()); + let response = futures::executor::block_on( + host.get_account_alias(&cx, account_alias_request("myapp.dot")), + ) + .unwrap(); + let HostAccountGetAliasResponse::V1(inner) = response; + assert_eq!(inner.context, [9; 32]); + assert_eq!(inner.alias, vec![1, 2, 3]); + let message = submitted_remote_message(&platform, &session); + let crate::host_logic::sso::messages::RemoteMessageData::V1( + crate::host_logic::sso::messages::v1::RemoteMessage::RingVrfAliasRequest(request), + ) = message.data + else { + panic!("expected ring VRF alias request"); + }; + assert_eq!(request.product_account_id.dot_ns_identifier, "myapp.dot"); + assert_eq!(request.product_id, "myapp.dot"); + } + + #[test] + fn get_account_alias_normalizes_remote_request_identifier() { + let session = sso_session_info(); + let platform = Arc::new(StubPlatform { + rpc_responses: sso_success_responses( + &session, + "alias-1", + crate::host_logic::sso::messages::RemoteMessage { + message_id: "wallet-alias-1".to_string(), + data: crate::host_logic::sso::messages::RemoteMessageData::V1( + crate::host_logic::sso::messages::v1::RemoteMessage::RingVrfAliasResponse( + crate::host_logic::sso::messages::RingVrfAliasResponse { + responding_to: "alias-1".to_string(), + payload: Ok(v01::HostAccountGetAliasResponse { + context: [9; 32], + alias: vec![1, 2, 3], + }), + }, + ), + ), + }, + ), + ..Default::default() + }); + let host = ProductRuntimeHost::new( + platform.clone(), + runtime_config("MyApp.DOT"), + test_spawner(), + ); + host.test_session_state().set_session(session.clone()); + let cx = CallContext::with_request_id("alias-1".to_string()); + futures::executor::block_on( + host.get_account_alias(&cx, account_alias_request("MyApp.DOT")), + ) + .unwrap(); + let message = submitted_remote_message(&platform, &session); + let crate::host_logic::sso::messages::RemoteMessageData::V1( + crate::host_logic::sso::messages::v1::RemoteMessage::RingVrfAliasRequest(request), + ) = message.data + else { + panic!("expected ring VRF alias request"); + }; + assert_eq!(request.product_account_id.dot_ns_identifier, "myapp.dot"); + assert_eq!(request.product_id, "myapp.dot"); + } + + #[test] + fn get_account_alias_cross_domain_rejects_when_user_declines() { + let host = + ProductRuntimeHost::new(stub_platform(), runtime_config("myapp.dot"), test_spawner()); + host.test_session_state().set_session(session_info()); + let cx = CallContext::new(); + let err = futures::executor::block_on( + host.get_account_alias(&cx, account_alias_request("other.dot")), + ) + .unwrap_err(); + assert!(matches!( + err, + CallError::Domain(HostAccountGetAliasError::V1( + v01::HostAccountGetError::Rejected + )) + )); + } + + #[test] + fn get_account_alias_cross_domain_maps_confirmation_failure_to_host_failure() { + let host = ProductRuntimeHost::new( + Arc::new(StubPlatform { + account_alias_error: Some("modal failed"), + ..Default::default() + }), + runtime_config("myapp.dot"), + test_spawner(), + ); + host.test_session_state().set_session(session_info()); + let cx = CallContext::new(); + let err = futures::executor::block_on( + host.get_account_alias(&cx, account_alias_request("other.dot")), + ) + .unwrap_err(); + assert!( + matches!(err, CallError::HostFailure { reason } if reason.contains("modal failed")) + ); + } + + #[test] + fn get_account_alias_cross_domain_accepts_confirmation_then_returns_sso_response() { + let session = sso_session_info(); + let platform = Arc::new(StubPlatform { + account_alias_confirmed: true, + rpc_responses: sso_success_responses( + &session, + "alias-2", + crate::host_logic::sso::messages::RemoteMessage { + message_id: "wallet-alias-2".to_string(), + data: crate::host_logic::sso::messages::RemoteMessageData::V1( + crate::host_logic::sso::messages::v1::RemoteMessage::RingVrfAliasResponse( + crate::host_logic::sso::messages::RingVrfAliasResponse { + responding_to: "alias-2".to_string(), + payload: Ok(v01::HostAccountGetAliasResponse { + context: [8; 32], + alias: vec![4, 5, 6], + }), + }, + ), + ), + }, + ), + ..Default::default() + }); + let host = ProductRuntimeHost::new( + platform.clone(), + runtime_config("myapp.dot"), + test_spawner(), + ); + host.test_session_state().set_session(session.clone()); + let cx = CallContext::with_request_id("alias-2".to_string()); + let response = futures::executor::block_on( + host.get_account_alias(&cx, account_alias_request("other.dot")), + ) + .unwrap(); + let HostAccountGetAliasResponse::V1(inner) = response; + assert_eq!(inner.context, [8; 32]); + assert_eq!(inner.alias, vec![4, 5, 6]); + let message = submitted_remote_message(&platform, &session); + assert!(matches!( + message.data, + crate::host_logic::sso::messages::RemoteMessageData::V1( + crate::host_logic::sso::messages::v1::RemoteMessage::RingVrfAliasRequest(_) + ) + )); + } + + #[test] + fn get_legacy_accounts_returns_derived_slot_zero_when_connected() { + let host = ProductRuntimeHost::new( + stub_platform(), + runtime_config("localhost:3000"), + test_spawner(), + ); + host.test_session_state().set_session(session_info()); + let cx = CallContext::new(); + let response = futures::executor::block_on( + host.get_legacy_accounts(&cx, HostGetLegacyAccountsRequest::V1), + ) + .unwrap(); + let HostGetLegacyAccountsResponse::V1(inner) = response; + assert_eq!(inner.accounts.len(), 1); + assert_eq!(inner.accounts[0].name.as_deref(), Some("alice")); + assert_eq!( + hex::encode(&inner.accounts[0].public_key), + "1c822b488297fde8c60d9cbc5585839f70a69fb2c5c69daa66b6043c75184467" + ); + } + + #[test] + fn get_legacy_accounts_returns_empty_when_disconnected() { + let host = ProductRuntimeHost::new_compat(stub_platform(), test_spawner()); + let cx = CallContext::new(); + let response = futures::executor::block_on( + host.get_legacy_accounts(&cx, HostGetLegacyAccountsRequest::V1), + ) + .unwrap(); + let HostGetLegacyAccountsResponse::V1(inner) = response; + assert!(inner.accounts.is_empty()); + } + + #[test] + fn get_user_id_returns_primary_username() { + let platform = Arc::new(StubPlatform { + identity_disclosure_confirmed: true, + ..Default::default() + }); + let host = ProductRuntimeHost::new_compat(platform.clone(), test_spawner()); + host.test_session_state().set_session(session_info()); + let cx = CallContext::new(); + let response = + futures::executor::block_on(host.get_user_id(&cx, HostGetUserIdRequest::V1)).unwrap(); + let HostGetUserIdResponse::V1(inner) = response; + assert_eq!(inner.primary_username, "Alice Smith"); + assert_eq!(platform.identity_disclosure_calls.load(Ordering::SeqCst), 1); + } + + #[test] + fn get_user_id_caches_identity_disclosure_grant() { + let platform = Arc::new(StubPlatform { + identity_disclosure_confirmed: true, + ..Default::default() + }); + let host = ProductRuntimeHost::new_compat(platform.clone(), test_spawner()); + host.test_session_state().set_session(session_info()); + let cx = CallContext::new(); + + futures::executor::block_on(host.get_user_id(&cx, HostGetUserIdRequest::V1)).unwrap(); + futures::executor::block_on(host.get_user_id(&cx, HostGetUserIdRequest::V1)).unwrap(); + + assert_eq!(platform.identity_disclosure_calls.load(Ordering::SeqCst), 1); + let status = + futures::executor::block_on(host.permission_authorization_status( + PermissionAuthorizationRequest::IdentityDisclosure, + )) + .unwrap(); + assert_eq!(status, PermissionAuthorizationStatus::Authorized); + } + + #[test] + fn get_user_id_caches_identity_disclosure_denial() { + let platform = Arc::new(StubPlatform::default()); + let host = ProductRuntimeHost::new_compat(platform.clone(), test_spawner()); + host.test_session_state().set_session(session_info()); + let cx = CallContext::new(); + + let first = futures::executor::block_on(host.get_user_id(&cx, HostGetUserIdRequest::V1)) + .unwrap_err(); + let second = futures::executor::block_on(host.get_user_id(&cx, HostGetUserIdRequest::V1)) + .unwrap_err(); + + assert_eq!(platform.identity_disclosure_calls.load(Ordering::SeqCst), 1); + assert!(matches!( + first, + CallError::Domain(HostGetUserIdError::V1( + v01::HostGetUserIdError::PermissionDenied + )) + )); + assert!(matches!( + second, + CallError::Domain(HostGetUserIdError::V1( + v01::HostGetUserIdError::PermissionDenied + )) + )); + let status = + futures::executor::block_on(host.permission_authorization_status( + PermissionAuthorizationRequest::IdentityDisclosure, + )) + .unwrap(); + assert_eq!(status, PermissionAuthorizationStatus::Denied); + } + + #[test] + fn get_user_id_respects_pre_authorized_identity_disclosure() { + let platform = Arc::new(StubPlatform::default()); + let host = ProductRuntimeHost::new_compat(platform.clone(), test_spawner()); + host.test_session_state().set_session(session_info()); + let cx = CallContext::new(); + futures::executor::block_on(host.set_permission_authorization_status( + PermissionAuthorizationRequest::IdentityDisclosure, + PermissionAuthorizationStatus::Authorized, + )) + .unwrap(); + + let response = + futures::executor::block_on(host.get_user_id(&cx, HostGetUserIdRequest::V1)).unwrap(); + let HostGetUserIdResponse::V1(inner) = response; + assert_eq!(inner.primary_username, "Alice Smith"); + assert_eq!(platform.identity_disclosure_calls.load(Ordering::SeqCst), 0); + } + + #[test] + fn derive_entropy_matches_dotli_vector() { + let host = + ProductRuntimeHost::new(stub_platform(), runtime_config("myapp.dot"), test_spawner()); + let mut session = sso_session_info(); + session.root_entropy_source = session_info().root_entropy_source; + host.test_session_state().set_session(session); + let cx = CallContext::new(); + let request = HostDeriveEntropyRequest::V1(v01::HostDeriveEntropyRequest { + context: b"product-key".to_vec(), + }); + let response = futures::executor::block_on(host.derive(&cx, request)).unwrap(); + let HostDeriveEntropyResponse::V1(inner) = response; + assert_eq!( + hex::encode(inner.entropy), + "ab1887248c9de3cf4b8c5a255782796d3d35a98c8eb2d7df61a410db8b14da36" + ); + } + + #[test] + fn derive_entropy_requires_session() { + let host = ProductRuntimeHost::new_compat(stub_platform(), test_spawner()); + let cx = CallContext::new(); + let request = HostDeriveEntropyRequest::V1(v01::HostDeriveEntropyRequest { + context: b"product-key".to_vec(), + }); + let err = futures::executor::block_on(host.derive(&cx, request)).unwrap_err(); + match err { + CallError::Domain(HostDeriveEntropyError::V1( + v01::HostDeriveEntropyError::Unknown { reason }, + )) => assert_eq!(reason, "Not connected"), + other => panic!("expected Unknown entropy error, got {other:?}"), + } + } + + #[test] + fn derive_entropy_requires_secret() { + let host = ProductRuntimeHost::new_compat(stub_platform(), test_spawner()); + let mut session = sso_session_info(); + session.root_entropy_source = None; + host.test_session_state().set_session(session); + let cx = CallContext::new(); + let request = HostDeriveEntropyRequest::V1(v01::HostDeriveEntropyRequest { + context: b"product-key".to_vec(), + }); + let err = futures::executor::block_on(host.derive(&cx, request)).unwrap_err(); + match err { + CallError::Domain(HostDeriveEntropyError::V1( + v01::HostDeriveEntropyError::Unknown { reason }, + )) => assert_eq!(reason, "Session secret missing"), + other => panic!("expected Unknown entropy error, got {other:?}"), + } + } + + #[test] + fn derive_entropy_rejects_empty_context_like_dotli_key() { + let host = ProductRuntimeHost::new_compat(stub_platform(), test_spawner()); + let mut session = sso_session_info(); + session.root_entropy_source = session_info().root_entropy_source; + host.test_session_state().set_session(session); + let cx = CallContext::new(); + let request = + HostDeriveEntropyRequest::V1(v01::HostDeriveEntropyRequest { context: vec![] }); + let err = futures::executor::block_on(host.derive(&cx, request)).unwrap_err(); + match err { + CallError::Domain(HostDeriveEntropyError::V1( + v01::HostDeriveEntropyError::Unknown { reason }, + )) => assert_eq!(reason, "\"key\" must be between 1 and 32 bytes, got 0"), + other => panic!("expected Unknown entropy error, got {other:?}"), + } + } + + #[test] + fn preimage_submit_confirms_and_delegates_to_platform() { + let host = ProductRuntimeHost::new_compat(stub_platform(), test_spawner()); + let cx = CallContext::new(); + let request = RemotePreimageSubmitRequest::V1(vec![1, 2, 3]); + let response = futures::executor::block_on(Preimage::submit(&host, &cx, request)).unwrap(); + assert_eq!(response, RemotePreimageSubmitResponse::V1(vec![1, 2, 3])); + } + + #[test] + fn preimage_submit_requires_remote_permission_before_backend_call() { + let preimage_submits = Arc::new(Mutex::new(Vec::new())); + let host = ProductRuntimeHost::new_compat( + Arc::new(StubPlatform { + remote_permission_denied: true, + preimage_submits: preimage_submits.clone(), + ..Default::default() + }), + test_spawner(), + ); + let cx = CallContext::new(); + let request = RemotePreimageSubmitRequest::V1(vec![1, 2, 3]); + let err = futures::executor::block_on(Preimage::submit(&host, &cx, request)).unwrap_err(); + match err { + CallError::Domain(RemotePreimageSubmitError::V1( + v01::PreimageSubmitError::Unknown { reason }, + )) => assert_eq!(reason, REMOTE_PERMISSION_DENIED_REASON), + other => panic!("expected preimage permission denial, got {other:?}"), + } + assert!( + preimage_submits + .lock() + .expect("preimage submit list mutex poisoned") + .is_empty() + ); + } + + #[test] + fn chain_broadcast_requires_remote_permission_before_backend_call() { + let platform = Arc::new(StubPlatform { + remote_permission_denied: true, + ..Default::default() + }); + let host = ProductRuntimeHost::new( + platform.clone(), + runtime_config("myapp.dot"), + test_spawner(), + ); + let cx = CallContext::new(); + let request = RemoteChainTransactionBroadcastRequest::V1( + v01::RemoteChainTransactionBroadcastRequest { + genesis_hash: vec![0; 32], + transaction: vec![1, 2, 3], + }, + ); + let err = futures::executor::block_on(Chain::broadcast_transaction(&host, &cx, request)) + .unwrap_err(); + match err { + CallError::Domain(RemoteChainTransactionBroadcastError::V1(v01::GenericError { + reason, + })) => assert_eq!(reason, REMOTE_PERMISSION_DENIED_REASON), + other => panic!("expected chain broadcast permission denial, got {other:?}"), + } + assert!(platform.sent_rpc.lock().unwrap().is_empty()); + } + + #[test] + fn preimage_lookup_subscribe_maps_platform_values() { + let host = ProductRuntimeHost::new_compat(stub_platform(), test_spawner()); + let cx = CallContext::new(); + let request = + RemotePreimageLookupSubscribeRequest::V1(v01::RemotePreimageLookupSubscribeRequest { + key: vec![0; 32], + }); + let mut subscription = futures::executor::block_on(host.lookup_subscribe(&cx, request)); + let item = futures::executor::block_on(subscription.next()).expect("preimage item"); + assert_eq!( + item, + RemotePreimageLookupSubscribeItem::V1(v01::RemotePreimageLookupSubscribeItem { + value: Some(vec![9, 8, 7]) + }) + ); + } + + #[test] + fn theme_subscribe_maps_platform_values() { + let host = ProductRuntimeHost::new_compat(stub_platform(), test_spawner()); + let cx = CallContext::new(); + let mut subscription = futures::executor::block_on(Theme::subscribe(&host, &cx)); + let item = futures::executor::block_on(subscription.next()).expect("theme item"); + assert_eq!( + item, + HostThemeSubscribeItem::V1(v01::HostThemeSubscribeItem { + name: v01::ThemeName::Default, + variant: v01::ThemeVariant::Dark, + }) + ); + } + + #[test] + fn sign_raw_rejects_invalid_product_account() { + let host = + ProductRuntimeHost::new(stub_platform(), runtime_config("myapp.dot"), test_spawner()); + host.test_session_state().set_session(session_info()); + let cx = CallContext::new(); + let request = HostSignRawRequest::V1(v01::HostSignRawRequest { + account: account_id("other.dot", 0), + payload: raw_payload(), + }); + let err = futures::executor::block_on(host.sign_raw(&cx, request)).unwrap_err(); + assert!(matches!( + err, + CallError::Domain(HostSignRawError::V1( + v01::HostSignPayloadError::PermissionDenied + )) + )); + } + + #[test] + fn sign_raw_rejects_without_session_after_valid_account() { + let host = + ProductRuntimeHost::new(stub_platform(), runtime_config("myapp.dot"), test_spawner()); + let cx = CallContext::new(); + let request = HostSignRawRequest::V1(v01::HostSignRawRequest { + account: account_id("myapp.dot", 0), + payload: raw_payload(), + }); + let err = futures::executor::block_on(host.sign_raw(&cx, request)).unwrap_err(); + assert!(matches!( + err, + CallError::Domain(HostSignRawError::V1(v01::HostSignPayloadError::Rejected)) + )); + } + + #[test] + fn sign_raw_denies_when_chain_submit_denied() { + let host = ProductRuntimeHost::new( + Arc::new(StubPlatform { + remote_permission_denied: true, + ..Default::default() + }), + runtime_config("myapp.dot"), + test_spawner(), + ); + host.test_session_state().set_session(session_info()); + let cx = CallContext::new(); + let request = HostSignRawRequest::V1(v01::HostSignRawRequest { + account: account_id("myapp.dot", 0), + payload: raw_payload(), + }); + let err = futures::executor::block_on(host.sign_raw(&cx, request)).unwrap_err(); + assert!(matches!( + err, + CallError::Domain(HostSignRawError::V1( + v01::HostSignPayloadError::PermissionDenied + )) + )); + } + + #[test] + fn sign_raw_rejects_when_user_declines_confirmation() { + let host = + ProductRuntimeHost::new(stub_platform(), runtime_config("myapp.dot"), test_spawner()); + host.test_session_state().set_session(session_info()); + let cx = CallContext::new(); + let request = HostSignRawRequest::V1(v01::HostSignRawRequest { + account: account_id("myapp.dot", 0), + payload: raw_payload(), + }); + let err = futures::executor::block_on(host.sign_raw(&cx, request)).unwrap_err(); + assert!(matches!( + err, + CallError::Domain(HostSignRawError::V1(v01::HostSignPayloadError::Rejected)) + )); + } + + #[test] + fn sign_raw_accepts_confirmation_then_returns_sso_response() { + let session = sso_session_info(); + let platform = Arc::new(StubPlatform { + sign_raw_confirmed: true, + rpc_responses: sso_success_responses( + &session, + "sign-raw-1", + sign_response_message("sign-raw-1", vec![7, 7], None), + ), + ..Default::default() + }); + let host = ProductRuntimeHost::new( + platform.clone(), + runtime_config("myapp.dot"), + test_spawner(), + ); + host.test_session_state().set_session(session.clone()); + let cx = CallContext::with_request_id("sign-raw-1".to_string()); + let request = HostSignRawRequest::V1(v01::HostSignRawRequest { + account: account_id("myapp.dot", 0), + payload: raw_payload(), + }); + let response = futures::executor::block_on(host.sign_raw(&cx, request)).unwrap(); + let HostSignRawResponse::V1(inner) = response; + assert_eq!(inner.signature, vec![7, 7]); + assert_eq!(inner.signed_transaction, None); + let message = submitted_remote_message(&platform, &session); + assert!(matches!( + &message.data, + crate::host_logic::sso::messages::RemoteMessageData::V1( + crate::host_logic::sso::messages::v1::RemoteMessage::SignRequest(request) + ) if matches!( + request.as_ref(), + crate::host_logic::sso::messages::SigningRequest::Raw(_) + ) + )); + let sent = platform.sent_rpc.lock().expect("rpc list mutex poisoned"); + let methods = sent + .iter() + .map(|request| { + serde_json::from_str::(request).unwrap()["method"] + .as_str() + .unwrap() + .to_string() + }) + .collect::>(); + assert_eq!( + methods, + vec![ + "statement_subscribeStatement", + "statement_subscribeStatement", + "statement_submit", + "statement_unsubscribeStatement", + "statement_unsubscribeStatement", + ] + ); + let mut unsubscribe_ids = sent[3..] + .iter() + .map(|request| serde_json::from_str::(request).unwrap()) + .map(|request| request["params"][0].as_str().unwrap().to_string()) + .collect::>(); + unsubscribe_ids.sort(); + assert_eq!( + unsubscribe_ids, + vec!["own-sub-sign-raw-1", "peer-sub-sign-raw-1"] + ); + } + + #[test] + fn sign_raw_uses_call_context_timeout_for_sso_response_wait() { + let session = sso_session_info(); + let message_id = "sign-raw-timeout"; + let mut rpc_responses = sso_success_responses( + &session, + message_id, + sign_response_message(message_id, vec![], None), + ); + rpc_responses.truncate(3); + let platform = Arc::new(StubPlatform { + sign_raw_confirmed: true, + rpc_responses, + ..Default::default() + }); + let host = ProductRuntimeHost::new( + platform.clone(), + runtime_config("myapp.dot"), + test_spawner(), + ); + host.test_session_state().set_session(session); + let mut cx = CallContext::with_request_id(message_id.to_string()); + cx.set_timeout(std::time::Duration::from_millis(1)); + let request = HostSignRawRequest::V1(v01::HostSignRawRequest { + account: account_id("myapp.dot", 0), + payload: raw_payload(), + }); + let err = futures::executor::block_on(host.sign_raw(&cx, request)).unwrap_err(); + + match err { + CallError::Domain(HostSignRawError::V1(v01::HostSignPayloadError::Unknown { + reason, + })) => assert_eq!( + reason, + "Account authority request timed out after 1ms for sign-raw-timeout" + ), + other => panic!("expected SSO response timeout, got {other:?}"), + } + + wait_until( + || recorded_rpc_method_count(&platform.sent_rpc, "statement_unsubscribeStatement") == 2, + "timed-out SSO request did not unsubscribe statement streams", + ); + } + + #[test] + fn sign_raw_cancellation_unsubscribes_sso_subscriptions() { + let session = sso_session_info(); + let message_id = "sign-raw-cancel"; + let platform = Arc::new(StubPlatform { + sign_raw_confirmed: true, + rpc_responses: vec![ + subscribe_ack_frame("truapi:1", "own-sub-sign-raw-cancel"), + subscribe_ack_frame("truapi:2", "peer-sub-sign-raw-cancel"), + ], + ..Default::default() + }); + let host = ProductRuntimeHost::new( + platform.clone(), + runtime_config("myapp.dot"), + test_spawner(), + ); + host.test_session_state().set_session(session); + let cancel = truapi::CancellationToken::new(); + let cx = CallContext::with_parts(message_id.to_string(), cancel.clone()); + let request = HostSignRawRequest::V1(v01::HostSignRawRequest { + account: account_id("myapp.dot", 0), + payload: raw_payload(), + }); + let handle = std::thread::spawn(move || { + futures::executor::block_on(host.sign_raw(&cx, request)).unwrap_err() + }); + + wait_until( + || recorded_rpc_method_count(&platform.sent_rpc, "statement_subscribeStatement") == 2, + "SSO subscriptions were not established before cancellation", + ); + cancel.cancel(); + + let err = handle + .join() + .expect("sign_raw cancellation thread panicked"); + match err { + CallError::Domain(HostSignRawError::V1(v01::HostSignPayloadError::Unknown { + reason, + })) => { + assert!( + reason.contains("cancelled"), + "expected cancellation, got {reason}" + ); + assert!( + reason.contains(message_id), + "expected request id in cancellation reason, got {reason}" + ); + } + other => panic!("expected SSO cancellation, got {other:?}"), + } + + wait_until( + || recorded_rpc_method_count(&platform.sent_rpc, "statement_unsubscribeStatement") == 2, + "cancelled SSO request did not unsubscribe statement streams", + ); + } + + #[test] + fn sign_raw_peer_disconnect_clears_session_store_and_broadcasts() { + let session = sso_session_info(); + let platform = Arc::new(StubPlatform { + sign_raw_confirmed: true, + rpc_responses: sso_peer_disconnect_responses(&session, "sign-raw-disconnect"), + ..Default::default() + }); + let host = ProductRuntimeHost::new( + platform.clone(), + runtime_config("myapp.dot"), + test_spawner(), + ); + host.test_session_state().set_session(session); + let mut statuses = host.test_session_state().subscribe(); + assert_eq!( + futures::executor::block_on(statuses.next()).unwrap(), + HostAccountConnectionStatusSubscribeItem::V1( + v01::HostAccountConnectionStatusSubscribeItem::Connected + ) + ); + + let cx = CallContext::with_request_id("sign-raw-disconnect".to_string()); + let request = HostSignRawRequest::V1(v01::HostSignRawRequest { + account: account_id("myapp.dot", 0), + payload: raw_payload(), + }); + let err = futures::executor::block_on(host.sign_raw(&cx, request)).unwrap_err(); + + assert!(matches!( + err, + CallError::Domain(HostSignRawError::V1(v01::HostSignPayloadError::Rejected)) + )); + assert!(host.test_session_state().current().is_none()); + assert_eq!( + *platform + .session_clears + .lock() + .expect("session clear counter mutex poisoned"), + 1 + ); + assert_eq!( + futures::executor::block_on(statuses.next()).unwrap(), + HostAccountConnectionStatusSubscribeItem::V1( + v01::HostAccountConnectionStatusSubscribeItem::Disconnected + ) + ); + } + + #[test] + fn idle_peer_disconnect_monitor_clears_session_store_and_broadcasts() { + let session = sso_session_info(); + let platform = Arc::new(StubPlatform { + rpc_responses: sso_peer_disconnect_monitor_responses(&session), + ..Default::default() + }); + let (host_config, product) = runtime_config("myapp.dot"); + let (host, pairing_host) = ProductRuntimeHost::new_pairing_for_tests( + platform.clone(), + host_config, + product, + test_spawner(), + ); + host.test_session_state().set_session(session.clone()); + let mut statuses = host.test_session_state().subscribe(); + assert_eq!( + futures::executor::block_on(statuses.next()).unwrap(), + HostAccountConnectionStatusSubscribeItem::V1( + v01::HostAccountConnectionStatusSubscribeItem::Connected + ) + ); + + pairing_host.start_session_supervision_for_current_session(); + + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); + let disconnected = loop { + if let Some(item) = statuses.next().now_or_never() { + break item.expect("status stream ended"); + } + assert!( + std::time::Instant::now() < deadline, + "peer disconnect monitor did not emit Disconnected" + ); + std::thread::sleep(std::time::Duration::from_millis(5)); + }; + + assert!(host.test_session_state().current().is_none()); + assert_eq!( + *platform + .session_clears + .lock() + .expect("session clear counter mutex poisoned"), + 1 + ); + assert_eq!( + disconnected, + HostAccountConnectionStatusSubscribeItem::V1( + v01::HostAccountConnectionStatusSubscribeItem::Disconnected + ) + ); + } + + #[test] + fn sign_payload_denies_when_chain_submit_denied() { + let host = ProductRuntimeHost::new( + Arc::new(StubPlatform { + remote_permission_denied: true, + ..Default::default() + }), + runtime_config("myapp.dot"), + test_spawner(), + ); + host.test_session_state().set_session(session_info()); + let cx = CallContext::new(); + let request = HostSignPayloadRequest::V1(v01::HostSignPayloadRequest { + account: account_id("myapp.dot", 0), + payload: sign_payload_data(), + }); + let err = futures::executor::block_on(host.sign_payload(&cx, request)).unwrap_err(); + assert!(matches!( + err, + CallError::Domain(HostSignPayloadError::V1( + v01::HostSignPayloadError::PermissionDenied + )) + )); + } + + #[test] + fn sign_payload_maps_confirmation_failure_to_host_failure() { + let host = ProductRuntimeHost::new( + Arc::new(StubPlatform { + sign_payload_error: Some("modal failed"), + ..Default::default() + }), + runtime_config("myapp.dot"), + test_spawner(), + ); + host.test_session_state().set_session(session_info()); + let cx = CallContext::new(); + let request = HostSignPayloadRequest::V1(v01::HostSignPayloadRequest { + account: account_id("myapp.dot", 0), + payload: sign_payload_data(), + }); + let err = futures::executor::block_on(host.sign_payload(&cx, request)).unwrap_err(); + assert!( + matches!(err, CallError::HostFailure { reason } if reason.contains("modal failed")) + ); + } + + #[test] + fn sign_payload_accepts_confirmation_then_returns_sso_response() { + let session = sso_session_info(); + let platform = Arc::new(StubPlatform { + sign_payload_confirmed: true, + rpc_responses: sso_success_responses( + &session, + "sign-payload-1", + sign_response_message("sign-payload-1", vec![8, 8], Some(vec![0xab, 0xcd])), + ), + ..Default::default() + }); + let host = ProductRuntimeHost::new( + platform.clone(), + runtime_config("myapp.dot"), + test_spawner(), + ); + host.test_session_state().set_session(session.clone()); + let cx = CallContext::with_request_id("sign-payload-1".to_string()); + let request = HostSignPayloadRequest::V1(v01::HostSignPayloadRequest { + account: account_id("myapp.dot", 0), + payload: sign_payload_data(), + }); + + let response = futures::executor::block_on(host.sign_payload(&cx, request)).unwrap(); + + let HostSignPayloadResponse::V1(inner) = response; + assert_eq!(inner.signature, vec![8, 8]); + assert_eq!(inner.signed_transaction, Some(vec![0xab, 0xcd])); + let message = submitted_remote_message(&platform, &session); + assert!(matches!( + &message.data, + crate::host_logic::sso::messages::RemoteMessageData::V1( + crate::host_logic::sso::messages::v1::RemoteMessage::SignRequest(request) + ) if matches!( + request.as_ref(), + crate::host_logic::sso::messages::SigningRequest::Payload(_) + ) + )); + } + + #[test] + fn create_transaction_accepts_confirmation_then_returns_sso_response() { + let session = sso_session_info(); + let platform = Arc::new(StubPlatform { + create_transaction_confirmed: true, + rpc_responses: sso_success_responses( + &session, + "create-tx-1", + crate::host_logic::sso::messages::RemoteMessage { + message_id: "wallet-create-tx-1".to_string(), + data: crate::host_logic::sso::messages::RemoteMessageData::V1( + crate::host_logic::sso::messages::v1::RemoteMessage::CreateTransactionResponse( + crate::host_logic::sso::messages::CreateTransactionResponse { + responding_to: "create-tx-1".to_string(), + signed_transaction: Ok(vec![0xca, 0xfe]), + }, + ), + ), + }, + ), + ..Default::default() + }); + let host = ProductRuntimeHost::new( + platform.clone(), + runtime_config("myapp.dot"), + test_spawner(), + ); + host.test_session_state().set_session(session.clone()); + let cx = CallContext::with_request_id("create-tx-1".to_string()); + let request = HostCreateTransactionRequest::V1(product_tx_payload("myapp.dot")); + let response = futures::executor::block_on(host.create_transaction(&cx, request)).unwrap(); + let HostCreateTransactionResponse::V1(inner) = response; + assert_eq!(inner.transaction, vec![0xca, 0xfe]); + let message = submitted_remote_message(&platform, &session); + assert!(matches!( + message.data, + crate::host_logic::sso::messages::RemoteMessageData::V1( + crate::host_logic::sso::messages::v1::RemoteMessage::CreateTransactionRequest(_) + ) + )); + } + + #[test] + fn legacy_sign_raw_rejects_signer_mismatch() { + let host = + ProductRuntimeHost::new(stub_platform(), runtime_config("myapp.dot"), test_spawner()); + host.test_session_state().set_session(session_info()); + let cx = CallContext::new(); + let request = + HostSignRawWithLegacyAccountRequest::V1(v01::HostSignRawWithLegacyAccountRequest { + signer: "5Ci5sCERp3MFEDpF2jVkQDJoBevpRosB7toYRqKWShewhdhq".to_string(), + payload: raw_payload(), + }); + let err = futures::executor::block_on(host.sign_raw_with_legacy_account(&cx, request)) + .unwrap_err(); + match err { + CallError::Domain(HostSignRawWithLegacyAccountError::V1( + v01::HostSignPayloadError::Unknown { reason }, + )) => assert_eq!(reason, "Account can't be derived from product account id"), + other => panic!("expected legacy signer mismatch, got {other:?}"), + } + } + + #[test] + fn legacy_sign_raw_denies_when_chain_submit_denied() { + let host = ProductRuntimeHost::new( + Arc::new(StubPlatform { + remote_permission_denied: true, + ..Default::default() + }), + runtime_config("myapp.dot"), + test_spawner(), + ); + host.test_session_state().set_session(sso_session_info()); + let cx = CallContext::new(); + let request = + HostSignRawWithLegacyAccountRequest::V1(v01::HostSignRawWithLegacyAccountRequest { + signer: "5CyFsdhwjXy7wWpDEM6isungQ3LfGnu9UXkt7paBQ6DYRxk1".to_string(), + payload: raw_payload(), + }); + let err = futures::executor::block_on(host.sign_raw_with_legacy_account(&cx, request)) + .unwrap_err(); + assert!(matches!( + err, + CallError::Domain(HostSignRawWithLegacyAccountError::V1( + v01::HostSignPayloadError::PermissionDenied + )) + )); + } + + #[test] + fn legacy_sign_raw_accepts_derived_ss58_then_returns_sso_response() { + let session = sso_session_info(); + let platform = Arc::new(StubPlatform { + sign_raw_confirmed: true, + rpc_responses: sso_success_responses( + &session, + "legacy-sign-raw-1", + sign_response_message("legacy-sign-raw-1", vec![9, 9], None), + ), + ..Default::default() + }); + let host = ProductRuntimeHost::new( + platform.clone(), + runtime_config("myapp.dot"), + test_spawner(), + ); + host.test_session_state().set_session(session.clone()); + let cx = CallContext::with_request_id("legacy-sign-raw-1".to_string()); + let request = + HostSignRawWithLegacyAccountRequest::V1(v01::HostSignRawWithLegacyAccountRequest { + signer: "5CyFsdhwjXy7wWpDEM6isungQ3LfGnu9UXkt7paBQ6DYRxk1".to_string(), + payload: raw_payload(), + }); + let response = + futures::executor::block_on(host.sign_raw_with_legacy_account(&cx, request)).unwrap(); + let HostSignRawWithLegacyAccountResponse::V1(inner) = response; + assert_eq!(inner.signature, vec![9, 9]); + assert_eq!(inner.signed_transaction, None); + let message = submitted_remote_message(&platform, &session); + let crate::host_logic::sso::messages::RemoteMessageData::V1( + crate::host_logic::sso::messages::v1::RemoteMessage::SignRequest(request), + ) = message.data + else { + panic!("expected product raw signing request"); + }; + let crate::host_logic::sso::messages::SigningRequest::Raw(request) = *request else { + panic!("expected raw signing payload"); + }; + assert_eq!( + request.product_account_id, + v01::ProductAccountId { + dot_ns_identifier: "myapp.dot".to_string(), + derivation_index: 0, + } + ); + assert!(matches!( + &request.data, + crate::host_logic::sso::messages::SigningRawPayload::Bytes(bytes) + if bytes == b"hello" + )); + } + + #[test] + fn legacy_sign_raw_accepts_derived_hex_then_returns_sso_response() { + let session = sso_session_info(); + let signer = derive_product_public_key(session.public_key, "myapp.dot", 0).unwrap(); + let platform = Arc::new(StubPlatform { + sign_raw_confirmed: true, + rpc_responses: sso_success_responses( + &session, + "legacy-sign-raw-hex-1", + sign_response_message("legacy-sign-raw-hex-1", vec![8, 8], None), + ), + ..Default::default() + }); + let host = ProductRuntimeHost::new( + platform.clone(), + runtime_config("myapp.dot"), + test_spawner(), + ); + host.test_session_state().set_session(session.clone()); + let cx = CallContext::with_request_id("legacy-sign-raw-hex-1".to_string()); + let request = + HostSignRawWithLegacyAccountRequest::V1(v01::HostSignRawWithLegacyAccountRequest { + signer: format!("0x{}", hex::encode(signer)), + payload: raw_payload(), + }); + let response = + futures::executor::block_on(host.sign_raw_with_legacy_account(&cx, request)).unwrap(); + let HostSignRawWithLegacyAccountResponse::V1(inner) = response; + assert_eq!(inner.signature, vec![8, 8]); + + let message = submitted_remote_message(&platform, &session); + let crate::host_logic::sso::messages::RemoteMessageData::V1( + crate::host_logic::sso::messages::v1::RemoteMessage::SignRequest(request), + ) = message.data + else { + panic!("expected product raw signing request"); + }; + let crate::host_logic::sso::messages::SigningRequest::Raw(request) = *request else { + panic!("expected raw signing payload"); + }; + assert_eq!( + request.product_account_id, + v01::ProductAccountId { + dot_ns_identifier: "myapp.dot".to_string(), + derivation_index: 0, + } + ); + } + + #[test] + fn legacy_create_transaction_rejects_raw_key_mismatch() { + let host = + ProductRuntimeHost::new(stub_platform(), runtime_config("myapp.dot"), test_spawner()); + host.test_session_state().set_session(session_info()); + let cx = CallContext::new(); + let request = + HostCreateTransactionWithLegacyAccountRequest::V1(v01::LegacyAccountTxPayload { + signer: [0; 32], + genesis_hash: [1; 32], + call_data: vec![0], + extensions: vec![], + tx_ext_version: 0, + }); + let err = + futures::executor::block_on(host.create_transaction_with_legacy_account(&cx, request)) + .unwrap_err(); + match err { + CallError::Domain(HostCreateTransactionWithLegacyAccountError::V1( + v01::HostCreateTransactionError::Unknown { reason }, + )) => assert_eq!(reason, "Account can't be derived from product account id"), + other => panic!("expected legacy signer mismatch, got {other:?}"), + } + } + + #[test] + fn legacy_create_transaction_accepts_derived_key_then_returns_sso_response() { + let session = sso_session_info(); + let signer = derive_product_public_key(session.public_key, "myapp.dot", 0).unwrap(); + let platform = Arc::new(StubPlatform { + create_transaction_confirmed: true, + rpc_responses: sso_success_responses( + &session, + "legacy-create-tx-1", + crate::host_logic::sso::messages::RemoteMessage { + message_id: "wallet-legacy-create-tx-1".to_string(), + data: crate::host_logic::sso::messages::RemoteMessageData::V1( + crate::host_logic::sso::messages::v1::RemoteMessage::CreateTransactionResponse( + crate::host_logic::sso::messages::CreateTransactionResponse { + responding_to: "legacy-create-tx-1".to_string(), + signed_transaction: Ok(vec![0xca, 0xfe]), + }, + ), + ), + }, + ), + ..Default::default() + }); + let host = ProductRuntimeHost::new( + platform.clone(), + runtime_config("myapp.dot"), + test_spawner(), + ); + host.test_session_state().set_session(session.clone()); + let cx = CallContext::with_request_id("legacy-create-tx-1".to_string()); + let request = + HostCreateTransactionWithLegacyAccountRequest::V1(v01::LegacyAccountTxPayload { + signer, + genesis_hash: [1; 32], + call_data: vec![0], + extensions: vec![], + tx_ext_version: 0, + }); + + let response = + futures::executor::block_on(host.create_transaction_with_legacy_account(&cx, request)) + .unwrap(); + + let HostCreateTransactionWithLegacyAccountResponse::V1(inner) = response; + assert_eq!(inner.transaction, vec![0xca, 0xfe]); + let message = submitted_remote_message(&platform, &session); + let crate::host_logic::sso::messages::RemoteMessageData::V1( + crate::host_logic::sso::messages::v1::RemoteMessage::CreateTransactionRequest(request), + ) = message.data + else { + panic!("expected product transaction request"); + }; + let crate::host_logic::sso::messages::CreateTransactionPayload::V1(payload) = + request.payload; + assert_eq!( + payload.signer, + v01::ProductAccountId { + dot_ns_identifier: "myapp.dot".to_string(), + derivation_index: 0, + } + ); + } + + #[test] + fn create_transaction_rejects_invalid_product_account() { + let host = + ProductRuntimeHost::new(stub_platform(), runtime_config("myapp.dot"), test_spawner()); + host.test_session_state().set_session(session_info()); + let cx = CallContext::new(); + let request = HostCreateTransactionRequest::V1(product_tx_payload("other.dot")); + let err = futures::executor::block_on(host.create_transaction(&cx, request)).unwrap_err(); + assert!(matches!( + err, + CallError::Domain(HostCreateTransactionError::V1( + v01::HostCreateTransactionError::PermissionDenied + )) + )); + } + + #[test] + fn resource_allocation_rejects_without_session() { + let host = ProductRuntimeHost::new_compat(stub_platform(), test_spawner()); + let cx = CallContext::new(); + let err = futures::executor::block_on(ResourceAllocation::request( + &host, + &cx, + resource_allocation_request(), + )) + .unwrap_err(); + match err { + CallError::Domain(HostRequestResourceAllocationError::V1( + v01::ResourceAllocationError::Unknown { reason }, + )) => assert_eq!(reason, "No active session"), + other => panic!("expected no-session resource allocation error, got {other:?}"), + } + } + + #[test] + fn resource_allocation_rejects_when_user_declines() { + let host = ProductRuntimeHost::new_compat(stub_platform(), test_spawner()); + host.test_session_state().set_session(session_info()); + let cx = CallContext::new(); + let err = futures::executor::block_on(ResourceAllocation::request( + &host, + &cx, + resource_allocation_request(), + )) + .unwrap_err(); + match err { + CallError::Domain(HostRequestResourceAllocationError::V1( + v01::ResourceAllocationError::Unknown { reason }, + )) => assert_eq!(reason, "User rejected resource allocation"), + other => panic!("expected user-rejected resource allocation error, got {other:?}"), + } + } + + #[test] + fn resource_allocation_maps_confirmation_failure_to_host_failure() { + let host = ProductRuntimeHost::new_compat( + Arc::new(StubPlatform { + resource_allocation_error: Some("modal failed"), + ..Default::default() + }), + test_spawner(), + ); + host.test_session_state().set_session(session_info()); + let cx = CallContext::new(); + let err = futures::executor::block_on(ResourceAllocation::request( + &host, + &cx, + resource_allocation_request(), + )) + .unwrap_err(); + assert!( + matches!(err, CallError::HostFailure { reason } if reason.contains("modal failed")) + ); + } + + #[test] + fn resource_allocation_accepts_confirmation_then_returns_sso_response() { + let session = sso_session_info(); + let platform = Arc::new(StubPlatform { + resource_allocation_confirmed: true, + rpc_responses: sso_success_responses( + &session, + "alloc-1", + crate::host_logic::sso::messages::RemoteMessage { + message_id: "wallet-alloc-1".to_string(), + data: crate::host_logic::sso::messages::RemoteMessageData::V1( + crate::host_logic::sso::messages::v1::RemoteMessage::ResourceAllocationResponse( + crate::host_logic::sso::messages::ResourceAllocationResponse { + responding_to: "alloc-1".to_string(), + payload: Ok(vec![ + crate::host_logic::sso::messages::SsoAllocationOutcome::Allocated( + crate::host_logic::sso::messages::SsoAllocatedResource::StatementStoreAllowance { + slot_account_key: vec![1], + }, + ), + crate::host_logic::sso::messages::SsoAllocationOutcome::Rejected, + crate::host_logic::sso::messages::SsoAllocationOutcome::NotAvailable, + ]), + }, + ), + ), + }, + ), + ..Default::default() + }); + let host = ProductRuntimeHost::new_compat(platform.clone(), test_spawner()); + host.test_session_state().set_session(session.clone()); + let cx = CallContext::with_request_id("alloc-1".to_string()); + let response = futures::executor::block_on(ResourceAllocation::request( + &host, + &cx, + resource_allocation_request(), + )) + .unwrap(); + let HostRequestResourceAllocationResponse::V1(inner) = response; + assert_eq!( + inner.outcomes, + vec![ + v01::AllocationOutcome::Allocated, + v01::AllocationOutcome::Rejected, + v01::AllocationOutcome::NotAvailable, + ] + ); + let message = submitted_remote_message(&platform, &session); + assert!(matches!( + message.data, + crate::host_logic::sso::messages::RemoteMessageData::V1( + crate::host_logic::sso::messages::v1::RemoteMessage::ResourceAllocationRequest(_) + ) + )); + } + + #[test] + fn session_store_sync_restores_valid_blob_from_tick() { + let stored = sso_session_info(); + let platform = Arc::new(StubPlatform { + session_blob: Some(crate::host_logic::session::encode_persisted_session( + &stored, + )), + ..Default::default() + }); + let (host, pairing_host) = + ProductRuntimeHost::new_compat_with_pairing(platform.clone(), test_spawner()); + + pairing_host + .clone() + .start_session_store_sync_for_tests(test_spawner()); + wait_until( + || host.test_session_state().current() == Some(stored.clone()), + "session store sync did not restore valid blob", + ); + + assert_eq!(host.test_session_state().current(), Some(stored.clone())); + let expected_auth_states = vec![AuthState::Connected(connected_session_ui_info(&stored))]; + wait_until( + || { + *platform + .auth_states + .lock() + .expect("auth state list mutex poisoned") + == expected_auth_states + }, + "session store sync did not broadcast connected auth state", + ); + assert_eq!( + *platform + .auth_states + .lock() + .expect("auth state list mutex poisoned"), + expected_auth_states + ); + } + + #[test] + fn session_store_sync_replaces_valid_blob_and_broadcasts_connected() { + let mut replacement = sso_session_info(); + replacement.public_key = [0x44; 32]; + let (host, pairing_host) = ProductRuntimeHost::new_compat_with_pairing( + Arc::new(StubPlatform { + session_blob: Some(crate::host_logic::session::encode_persisted_session( + &replacement, + )), + ..Default::default() + }), + test_spawner(), + ); + host.test_session_state().set_session(sso_session_info()); + let mut statuses = host.test_session_state().subscribe(); + let _ = futures::executor::block_on(statuses.next()); + + pairing_host + .clone() + .start_session_store_sync_for_tests(test_spawner()); + + assert_eq!( + futures::executor::block_on(statuses.next()).unwrap(), + HostAccountConnectionStatusSubscribeItem::V1( + v01::HostAccountConnectionStatusSubscribeItem::Connected + ) + ); + assert_eq!(host.test_session_state().current(), Some(replacement)); + } + + #[test] + fn session_store_sync_clears_invalid_blob() { + let platform = Arc::new(StubPlatform { + session_blob: Some(vec![0xff]), + ..Default::default() + }); + let (host, pairing_host) = + ProductRuntimeHost::new_compat_with_pairing(platform.clone(), test_spawner()); + host.test_session_state().set_session(sso_session_info()); + + pairing_host + .clone() + .start_session_store_sync_for_tests(test_spawner()); + wait_until( + || host.test_session_state().current().is_none(), + "session store sync did not clear invalid blob", + ); + + assert!(host.test_session_state().current().is_none()); + // `set_session` bypasses the auth state cell, so the cell never left + // `Disconnected` and clearing the invalid blob emits nothing. + assert!( + platform + .auth_states + .lock() + .expect("auth state list mutex poisoned") + .is_empty() + ); + } + + #[test] + fn session_store_sync_clears_unreadable_blob() { + let session_clears = Arc::new(Mutex::new(0)); + let (host, pairing_host) = ProductRuntimeHost::new_compat_with_pairing( + Arc::new(StubPlatform { + session_error: Some("storage unavailable"), + session_clears: session_clears.clone(), + ..Default::default() + }), + test_spawner(), + ); + host.test_session_state().set_session(sso_session_info()); + + pairing_host + .clone() + .start_session_store_sync_for_tests(test_spawner()); + wait_until( + || *session_clears.lock().unwrap() == 1, + "session store sync did not clear unreadable blob", + ); + + assert!(host.test_session_state().current().is_none()); + assert_eq!(*session_clears.lock().unwrap(), 1); + } + + /// A persistently failing read clears the backing store once for the + /// initial sync tick. Further clears require explicit host notifications. + #[test] + fn session_store_sync_clears_once_on_initial_persistent_read_error() { + let session_clears = Arc::new(Mutex::new(0)); + let (host, pairing_host) = ProductRuntimeHost::new_compat_with_pairing( + Arc::new(StubPlatform { + session_error: Some("storage unavailable"), + session_clears: session_clears.clone(), + ..Default::default() + }), + test_spawner(), + ); + host.test_session_state().set_session(sso_session_info()); + + pairing_host + .clone() + .start_session_store_sync_for_tests(test_spawner()); + + wait_until( + || *session_clears.lock().unwrap() == 1, + "clear_stored_session was never called", + ); + assert_eq!(*session_clears.lock().unwrap(), 1); + assert!(host.test_session_state().current().is_none()); + } + + #[test] + fn disconnect_submits_disconnected_message_best_effort() { + let platform = Arc::new(StubPlatform::default()); + let host = ProductRuntimeHost::new_compat(platform.clone(), test_spawner()); + let session = sso_session_info(); + host.test_session_state().set_session(session.clone()); + + futures::executor::block_on(host.disconnect()); + + assert!(host.test_session_state().current().is_none()); + assert_eq!( + *platform + .session_clears + .lock() + .expect("session clear counter mutex poisoned"), + 1 + ); + let message = submitted_remote_message(&platform, &session); + assert_eq!(message.message_id, "truapi:sso:disconnect"); + assert!(matches!( + message.data, + RemoteMessageData::V1(v1::RemoteMessage::Disconnected) + )); + } + + #[test] + fn disconnect_clears_session_store_and_broadcasts_disconnected() { + let platform = Arc::new(StubPlatform::default()); + let host = ProductRuntimeHost::new_compat(platform.clone(), test_spawner()); + host.test_session_state().set_session(sso_session_info()); + platform + .local_storage + .lock() + .expect("local storage mutex poisoned") + .insert( + core_storage_test_key(CoreStorageKey::PairingDeviceIdentity), + vec![1, 2, 3], + ); + let mut statuses = host.test_session_state().subscribe(); + assert_eq!( + futures::executor::block_on(statuses.next()).unwrap(), + HostAccountConnectionStatusSubscribeItem::V1( + v01::HostAccountConnectionStatusSubscribeItem::Connected + ) + ); + + futures::executor::block_on(host.disconnect()); + + assert!(host.test_session_state().current().is_none()); + assert_eq!( + *platform + .session_clears + .lock() + .expect("session clear counter mutex poisoned"), + 1 + ); + assert!( + !platform + .local_storage + .lock() + .expect("local storage mutex poisoned") + .contains_key(&core_storage_test_key( + CoreStorageKey::PairingDeviceIdentity + )), + "logout must rotate the pairing device identity so stale statement-store responses cannot be replayed on the next login" + ); + assert_eq!( + futures::executor::block_on(statuses.next()).unwrap(), + HostAccountConnectionStatusSubscribeItem::V1( + v01::HostAccountConnectionStatusSubscribeItem::Disconnected + ) + ); + // `set_session` bypasses the auth state cell, so the cell never left + // `Disconnected` and the logout emits nothing new. + assert!( + platform + .auth_states + .lock() + .expect("auth state list mutex poisoned") + .is_empty() + ); + } + + #[test] + fn disconnect_emits_disconnected_auth_state_after_store_sync_connected() { + let stored = sso_session_info(); + let platform = Arc::new(StubPlatform { + session_blob: Some(crate::host_logic::session::encode_persisted_session( + &stored, + )), + ..Default::default() + }); + let (host, pairing_host) = + ProductRuntimeHost::new_compat_with_pairing(platform.clone(), test_spawner()); + pairing_host + .clone() + .start_session_store_sync_for_tests(test_spawner()); + wait_until( + || { + platform + .auth_states + .lock() + .expect("auth state list mutex poisoned") + .len() + == 1 + }, + "session store sync did not emit connected auth state", + ); + + futures::executor::block_on(host.disconnect()); + + assert_eq!( + *platform + .auth_states + .lock() + .expect("auth state list mutex poisoned"), + vec![ + AuthState::Connected(connected_session_ui_info(&stored)), + AuthState::Disconnected, + ] + ); + } + + #[test] + fn disconnect_tolerates_repeated_logout_when_already_disconnected() { + let platform = Arc::new(StubPlatform::default()); + let host = ProductRuntimeHost::new_compat(platform.clone(), test_spawner()); + + futures::executor::block_on(host.disconnect()); + futures::executor::block_on(host.disconnect()); + + assert!(host.test_session_state().current().is_none()); + assert_eq!( + *platform + .session_clears + .lock() + .expect("session clear counter mutex poisoned"), + 2 + ); + assert!(platform.sent_rpc.lock().unwrap().is_empty()); + } + + #[test] + fn permissions_grants_and_caches() { + let host = ProductRuntimeHost::new_compat(stub_platform(), test_spawner()); + let cx = CallContext::new(); + let request = HostDevicePermissionRequest::V1(v01::HostDevicePermissionRequest::Camera); + let response = + futures::executor::block_on(host.request_device_permission(&cx, request)).unwrap(); + let HostDevicePermissionResponse::V1(inner) = response; + assert!(inner.granted); + } + + #[test] + fn feature_supported_encodes_response_to_known_bytes() { + let host = ProductRuntimeHost::new_compat(stub_platform(), test_spawner()); + let cx = CallContext::new(); + let request = HostFeatureSupportedRequest::V1(v01::HostFeatureSupportedRequest::Chain { + genesis_hash: vec![0u8; 32], + }); + let response = futures::executor::block_on(host.feature_supported(&cx, request)).unwrap(); + // [V1 variant=0][supported=1] + assert_eq!(response.encode(), vec![0x00, 0x01]); + } +} diff --git a/rust/crates/truapi-server/src/runtime/auth_state.rs b/rust/crates/truapi-server/src/runtime/auth_state.rs new file mode 100644 index 00000000..1b0b8541 --- /dev/null +++ b/rust/crates/truapi-server/src/runtime/auth_state.rs @@ -0,0 +1,168 @@ +//! Core-owned auth/session UI state machine. Every [`AuthState`] emission to +//! the host funnels through [`AuthStateMachine`], so transitions stay ordered +//! and a stale session-store tick can never tear down an in-flight pairing. + +use std::sync::{Arc, Mutex}; + +use futures::channel::oneshot; +use truapi_platform::{AuthPresenter, AuthState, Platform, SessionUiInfo}; + +/// Serialized auth-state machine bound to the platform's `auth_state_changed` +/// sink. Each transition mutates under the lock, releases it, then emits the +/// new state (when it actually changed), so `auth_state_changed` handlers may +/// safely re-enter the runtime (e.g. a host cancelling the login it just +/// observed). The cancel channel for an in-flight login lives inside the +/// `Pairing` state, making its registration atomic with the transition. +pub(crate) struct AuthStateMachine { + platform: Arc, + inner: Arc>, +} + +impl Clone for AuthStateMachine { + fn clone(&self) -> Self { + Self { + platform: self.platform.clone(), + inner: self.inner.clone(), + } + } +} + +#[derive(Default)] +struct AuthStateInner { + state: AuthState, + /// Increments on every `pairing_started`; lets an abandoned flow's reset + /// guard distinguish its own `Pairing` from a newer flow's. + pairing_epoch: u64, + /// Resolves the in-flight login's cancel receiver. Present exactly while + /// the state is `Pairing`. + cancel_tx: Option>, +} + +impl AuthStateMachine { + /// Create an auth state machine that reports transitions to `platform`. + pub(super) fn new(platform: Arc) -> Self { + Self { + platform, + inner: Arc::new(Mutex::new(AuthStateInner::default())), + } + } + + /// Enter `Pairing`. Returns the cancel receiver and the pairing epoch, or + /// `None` when a pairing is already in flight (single-flight guard). + pub(super) fn pairing_started(&self, deeplink: String) -> Option<(oneshot::Receiver<()>, u64)> { + let (cancel_tx, cancel_rx) = oneshot::channel(); + let epoch = self.transition(|inner| { + if matches!(inner.state, AuthState::Pairing { .. }) { + return None; + } + inner.state = AuthState::Pairing { deeplink }; + inner.pairing_epoch = inner.pairing_epoch.wrapping_add(1); + inner.cancel_tx = Some(cancel_tx); + Some(inner.pairing_epoch) + })?; + Some((cancel_rx, epoch)) + } + + /// `Pairing` -> `LoginFailed`: the in-flight login reported a failure. + pub(super) fn login_failed(&self, reason: String) { + self.transition(|inner| { + if !matches!(inner.state, AuthState::Pairing { .. }) { + return None; + } + inner.cancel_tx = None; + inner.state = AuthState::LoginFailed { reason }; + Some(()) + }); + } + + /// `Disconnected`/`LoginFailed` -> `LoginFailed`: a login failed before + /// it reached `Pairing` (device identity or bootstrap errors). A no-op + /// while `Pairing`, so a concurrent second login attempt failing early + /// cannot tear down the first one's presentation. + pub(super) fn login_failed_before_pairing(&self, reason: String) { + self.transition(|inner| { + if matches!( + inner.state, + AuthState::Pairing { .. } | AuthState::Connected(_) + ) { + return None; + } + inner.state = AuthState::LoginFailed { reason }; + Some(()) + }); + } + + /// `Pairing`/`LoginFailed` -> `Disconnected` (host cancelled or + /// dismissed). Wakes the in-flight login, which resolves as `Rejected`. + pub(super) fn login_cancelled(&self) { + self.transition(|inner| { + if !matches!( + inner.state, + AuthState::Pairing { .. } | AuthState::LoginFailed { .. } + ) { + return None; + } + if let Some(cancel_tx) = inner.cancel_tx.take() { + let _ = cancel_tx.send(()); + } + inner.state = AuthState::Disconnected; + Some(()) + }); + } + + /// Any state -> `Connected`. A login in flight is cancelled: another + /// runtime won the race, and the waking flow resolves as + /// `AlreadyConnected`. Emits only when the connected info changed. + pub(super) fn connected(&self, info: &SessionUiInfo) { + self.transition(|inner| { + if let Some(cancel_tx) = inner.cancel_tx.take() { + let _ = cancel_tx.send(()); + } + if matches!(&inner.state, AuthState::Connected(current) if current == info) { + return None; + } + inner.state = AuthState::Connected(info.clone()); + Some(()) + }); + } + + /// Session store reports no session. A no-op while `Pairing`: the login + /// flow owns its own terminal transition, and a boot-time store tick must + /// not tear down the pairing UI. + pub(super) fn store_disconnected(&self) { + self.transition(|inner| { + if matches!( + inner.state, + AuthState::Pairing { .. } | AuthState::Disconnected + ) { + return None; + } + inner.state = AuthState::Disconnected; + Some(()) + }); + } + + /// Reset a `Pairing` left behind by a dropped login future, but only when + /// it still belongs to `epoch` (a newer flow's pairing is left alone). + pub(super) fn reset_abandoned_pairing(&self, epoch: u64) { + self.transition(|inner| { + if !matches!(inner.state, AuthState::Pairing { .. }) || inner.pairing_epoch != epoch { + return None; + } + inner.cancel_tx = None; + inner.state = AuthState::Disconnected; + Some(()) + }); + } + + /// Run `apply` under the lock; when it changed the state (returned + /// `Some`), emit the new state to the host after releasing the lock. + fn transition(&self, apply: impl FnOnce(&mut AuthStateInner) -> Option) -> Option { + let mut inner = self.inner.lock().expect("auth state mutex poisoned"); + let applied = apply(&mut inner)?; + let state = inner.state.clone(); + drop(inner); + AuthPresenter::auth_state_changed(self.platform.as_ref(), state); + Some(applied) + } +} diff --git a/rust/crates/truapi-server/src/runtime/authority.rs b/rust/crates/truapi-server/src/runtime/authority.rs new file mode 100644 index 00000000..c4c808fd --- /dev/null +++ b/rust/crates/truapi-server/src/runtime/authority.rs @@ -0,0 +1,280 @@ +//! Role-neutral account authority contracts used by product runtimes. +//! +//! Pairing and signing hosts implement these traits differently, but +//! `ProductRuntimeHost` can use this module's shared request/session types +//! without knowing where the key material lives. + +use async_trait::async_trait; +use core::fmt; +use core::time::Duration; +use std::sync::Arc; +use truapi::latest::{ + HostAccountGetAliasResponse, HostCreateTransactionResponse, + HostRequestResourceAllocationRequest, HostRequestResourceAllocationResponse, + HostSignPayloadRequest, HostSignPayloadResponse, HostSignPayloadWithLegacyAccountRequest, + HostSignRawRequest, HostSignRawWithLegacyAccountRequest, LegacyAccountTxPayload, + ProductAccountId, ProductAccountTxPayload, +}; +use truapi::versioned::account::{HostRequestLoginError, HostRequestLoginResponse}; +use truapi::{CallContext, CallError, CancellationReason}; +use truapi_platform::ProductContext; + +use crate::host_logic::session::{SessionInfo, SessionState}; + +/// Snapshot of an account-authority session selected by the authority. +/// +/// This is the neutral session projection product runtimes can use while +/// preserving authority-private material inside the concrete authority +/// implementation. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct AuthoritySession { + /// Root account public key for the active authority session. + pub public_key: [u8; 32], + /// Identity account resolved from the signing host, when available. + pub identity_account_id: Option<[u8; 32]>, + /// Lightweight username resolved from People-chain identity, when available. + pub lite_username: Option, + /// Fully qualified username resolved from People-chain identity, when available. + pub full_username: Option, + /// Opaque session token used to reject stale pre-confirmation snapshots. + pub validation_id: Vec, +} + +impl AuthoritySession { + pub(crate) fn from_session_info(info: &SessionInfo, validation_id: Vec) -> Self { + Self { + public_key: info.public_key, + identity_account_id: info.identity_account_id, + lite_username: info.lite_username.clone(), + full_username: info.full_username.clone(), + validation_id, + } + } +} + +/// Typed account-authority failure before it is mapped to an API-specific error. +#[derive(Debug, Clone, PartialEq, Eq, derive_more::Display)] +pub(crate) enum AuthorityError { + /// User or authority rejected the request. + #[display("Rejected")] + Rejected, + /// The selected authority session is no longer active. + #[display("Disconnected")] + Disconnected, + /// The authority call was cancelled before completion. + #[display("{_0}")] + Cancelled(AuthorityCancelError), + /// The authority cannot service the request. + #[display("{reason}")] + Unavailable { reason: String }, + /// Catch-all authority failure. + #[display("{reason}")] + Unknown { reason: String }, +} + +impl AuthorityError { + pub(crate) fn reason(self) -> String { + self.to_string() + } +} + +/// Cancellation cause for an account-authority call. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct AuthorityCancelError { + request_id: String, + reason: CancellationReason, +} + +impl AuthorityCancelError { + pub(crate) fn new(request_id: &str, reason: CancellationReason) -> Self { + Self { + request_id: request_id.to_string(), + reason, + } + } +} + +impl fmt::Display for AuthorityCancelError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let request = if self.request_id.is_empty() { + String::new() + } else { + format!(" for {}", self.request_id) + }; + match &self.reason { + CancellationReason::Cancelled => { + write!(f, "Account authority request cancelled{request}") + } + CancellationReason::TimedOut { timeout } => write!( + f, + "Account authority request timed out after {}{request}", + format_timeout_duration(*timeout) + ), + } + } +} + +fn format_timeout_duration(duration: Duration) -> String { + if duration.subsec_millis() == 0 { + format!("{}s", duration.as_secs()) + } else { + format!("{}ms", duration.as_millis()) + } +} + +/// Payload-signing request selected by the product API entrypoint. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum SignPayloadAuthorityRequest { + /// Sign a payload with a product-derived account. + Product(HostSignPayloadRequest), + /// Sign a payload through the legacy-account API. + LegacyAccount { + /// Product slot-zero account that backs the validated legacy signer. + product_account: ProductAccountId, + /// Original legacy-account request. + request: HostSignPayloadWithLegacyAccountRequest, + }, +} + +/// Raw-signing request selected by the product API entrypoint. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum SignRawAuthorityRequest { + /// Sign raw data with a product-derived account. + Product(HostSignRawRequest), + /// Sign raw data through the legacy-account API using the product slot-zero account. + LegacyAccount { + /// Product slot-zero account that backs the validated legacy signer. + product_account: ProductAccountId, + /// Original legacy-account request. + request: HostSignRawWithLegacyAccountRequest, + }, +} + +/// Transaction-creation request selected by the product API entrypoint. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum CreateTransactionAuthorityRequest { + /// Create a transaction with a product-derived account. + Product(ProductAccountTxPayload), + /// Create a transaction through the legacy-account API using the product slot-zero account. + LegacyAccount { + /// Product slot-zero account that backs the validated legacy signer. + product_account: ProductAccountId, + /// Original legacy-account transaction request. + request: LegacyAccountTxPayload, + }, +} + +/// Host-level account authority used by product runtimes. +/// +/// Pairing hosts implement this by forwarding authority requests to a paired +/// signing host. A signing-host implementation can later provide the same +/// surface from local keys without changing product runtime code. +#[async_trait] +pub(crate) trait ProductAuthority: Send + Sync { + /// Current account-authority session, if connected. + fn current_session(&self) -> Option; + + /// Shared session holder owned by this authority. + /// + /// Product runtimes use it for connection-status subscriptions. The + /// concrete authority keeps ownership of the actual session material. + fn session_state(&self) -> Arc; + + /// Request account connection for the calling product. + async fn request_login( + &self, + product: &ProductContext, + ) -> Result>; + + /// Disconnect the current account-authority session. + async fn disconnect(&self); + + /// Sign a SCALE transaction payload for a product account. + async fn sign_payload( + &self, + cx: &CallContext, + session: &AuthoritySession, + request: SignPayloadAuthorityRequest, + ) -> Result; + + /// Sign arbitrary bytes for a product account. + async fn sign_raw( + &self, + cx: &CallContext, + session: &AuthoritySession, + request: SignRawAuthorityRequest, + ) -> Result; + + /// Build and sign a transaction for a product account. + async fn create_transaction( + &self, + cx: &CallContext, + session: &AuthoritySession, + request: CreateTransactionAuthorityRequest, + ) -> Result; + + /// Request an alias proof for a product account in another product context. + async fn account_alias( + &self, + cx: &CallContext, + session: &AuthoritySession, + product_account_id: ProductAccountId, + requesting_product_id: String, + ) -> Result; + + /// Ask the account authority to allocate product-scoped resources. + async fn allocate_resources( + &self, + cx: &CallContext, + session: &AuthoritySession, + product_id: String, + request: HostRequestResourceAllocationRequest, + ) -> Result; + + /// Derive product-scoped entropy for a connected session. + fn derive_entropy( + &self, + session: &AuthoritySession, + product_id: &str, + context: &[u8], + ) -> Result<[u8; 32], AuthorityError>; +} + +/// Build the neutral authority-session snapshot for `session`. +pub(super) fn authority_session(session: &SessionInfo) -> AuthoritySession { + AuthoritySession::from_session_info(session, authority_session_validation_id(session)) +} + +/// Revalidate a pre-confirmation snapshot against the live session, returning +/// the current [`SessionInfo`] when it still matches. +/// +/// Both roles use this before touching key material: a snapshot taken before +/// user confirmation must still be the current authority session when the +/// signature or derivation happens, otherwise the request is rejected. +pub(super) fn require_current_session( + session_state: &SessionState, + session: &AuthoritySession, +) -> Result { + let current = session_state + .current() + .ok_or(AuthorityError::Disconnected)?; + if authority_session_validation_id(¤t) == session.validation_id { + Ok(current) + } else { + Err(AuthorityError::Disconnected) + } +} + +/// Opaque token identifying which concrete session a snapshot was taken from. +pub(super) fn authority_session_validation_id(session: &SessionInfo) -> Vec { + let mut id = Vec::with_capacity(67); + if let Some(sso) = &session.sso { + id.extend_from_slice(b"sso"); + id.extend_from_slice(&sso.session_id_own); + id.extend_from_slice(&sso.session_id_peer); + } else { + id.extend_from_slice(b"local"); + id.extend_from_slice(&session.public_key); + } + id +} diff --git a/rust/crates/truapi-server/src/runtime/identity.rs b/rust/crates/truapi-server/src/runtime/identity.rs new file mode 100644 index 00000000..0c36a897 --- /dev/null +++ b/rust/crates/truapi-server/src/runtime/identity.rs @@ -0,0 +1,163 @@ +//! People-chain identity lookup used to resolve usernames for a paired session. + +#[cfg(not(target_arch = "wasm32"))] +use std::time::Duration; +#[cfg(target_arch = "wasm32")] +use web_time::Duration; + +use crate::chain_runtime::{ + ChainRuntime, wait_for_chain_head_best_hash, wait_for_chain_head_storage_value, +}; +use crate::host_logic::identity::{ + PeopleIdentity, decode_people_identity, resources_consumers_storage_key, +}; +use crate::host_logic::session::SessionInfo; + +use tracing::{debug, instrument, warn}; +use truapi::latest::{ + OperationStartedResult, RemoteChainHeadFollowRequest, RemoteChainHeadStorageRequest, + StorageQueryItem, StorageQueryType, +}; + +/// Fill in missing usernames by querying the people chain; returns the +/// session unchanged when it already carries a username or no people chain +/// is configured. +#[instrument(skip_all, fields(runtime.method = "session.identity.resolve_with_chain"))] +pub(super) async fn resolve_session_identity_with_chain( + chain: &ChainRuntime, + people_chain_genesis_hash: [u8; 32], + mut session: SessionInfo, +) -> SessionInfo { + if session.has_username() || people_chain_genesis_hash == [0; 32] { + return session; + } + + let preferred_account = session.identity_account_id.unwrap_or(session.public_key); + if !lookup_and_apply( + chain, + people_chain_genesis_hash, + preferred_account, + &mut session, + "identity", + ) + .await + && preferred_account != session.public_key + { + let public_key = session.public_key; + lookup_and_apply( + chain, + people_chain_genesis_hash, + public_key, + &mut session, + "root identity", + ) + .await; + } + + session +} + +/// Look up `account`'s people-chain identity and apply any usernames to +/// `session`; returns whether a username record was found and applied. +async fn lookup_and_apply( + chain: &ChainRuntime, + people_chain_genesis_hash: [u8; 32], + account: [u8; 32], + session: &mut SessionInfo, + label: &str, +) -> bool { + match lookup_people_identity(chain, people_chain_genesis_hash, account).await { + Ok(Some(identity)) => { + debug!( + account = %hex::encode(account), + lite_username = identity.lite_username.as_deref().unwrap_or(""), + full_username = identity.full_username.as_deref().unwrap_or(""), + "People-chain {label} lookup found username" + ); + session.apply_usernames(identity.lite_username, identity.full_username); + true + } + Ok(None) => { + debug!( + account = %hex::encode(account), + "People-chain {label} lookup found no consumer record" + ); + false + } + Err(reason) => { + warn!( + account = %hex::encode(account), + %reason, + "People-chain {label} lookup failed" + ); + false + } + } +} + +#[instrument(skip_all, fields(runtime.method = "session.identity.lookup"))] +async fn lookup_people_identity( + chain: &ChainRuntime, + people_chain_genesis_hash: [u8; 32], + account_id: [u8; 32], +) -> Result, String> { + let genesis_hash = people_chain_genesis_hash.to_vec(); + let key = resources_consumers_storage_key(&account_id); + let lookup_id = { + use std::sync::atomic::{AtomicU64, Ordering}; + + /// Monotonic salt for local identity lookup follow ids, avoiding + /// collisions between concurrent People-chain identity lookups. + static IDENTITY_LOOKUP_COUNTER: AtomicU64 = AtomicU64::new(1); + + IDENTITY_LOOKUP_COUNTER.fetch_add(1, Ordering::Relaxed) + }; + let follow_id = format!("truapi:identity:{}:{}", lookup_id, hex::encode(account_id),); + let mut follow = chain.remote_chain_head_follow( + follow_id.clone(), + RemoteChainHeadFollowRequest { + genesis_hash: genesis_hash.clone(), + with_runtime: false, + }, + ); + + let hash = wait_for_chain_head_best_hash( + &mut follow, + "People-chain", + Duration::from_secs(10), + Duration::from_secs(2), + ) + .await?; + let response = chain + .remote_chain_head_storage(RemoteChainHeadStorageRequest { + genesis_hash, + follow_subscription_id: follow_id, + hash, + items: vec![StorageQueryItem { + key: key.clone(), + query_type: StorageQueryType::Value, + }], + child_trie: None, + }) + .await + .map_err(|failure| failure.reason())?; + + let operation_id = match response.operation { + OperationStartedResult::Started { operation_id } => operation_id, + OperationStartedResult::LimitReached => { + return Err("People-chain storage lookup limit reached".to_string()); + } + }; + let Some(value) = wait_for_chain_head_storage_value( + &mut follow, + &operation_id, + &key, + "People-chain", + Duration::from_secs(10), + ) + .await? + else { + return Ok(None); + }; + decode_people_identity(&value).map(Some) +} diff --git a/rust/crates/truapi-server/src/runtime/pairing_host.rs b/rust/crates/truapi-server/src/runtime/pairing_host.rs new file mode 100644 index 00000000..666c14a6 --- /dev/null +++ b/rust/crates/truapi-server/src/runtime/pairing_host.rs @@ -0,0 +1,593 @@ +//! Pairing-host role for inter-host account authority. +//! +//! A pairing host does not own the user's signing keys. It pairs with a signing +//! host, keeps the active inter-host session, and sends authority requests to +//! that signing host over the SSO channel in [`sso_channel`]. + +mod sso_channel; + +use std::sync::{Arc, Mutex, Weak}; + +use futures::channel::oneshot; +use sso_channel::SsoDisconnectMonitor; + +use super::auth_state::AuthStateMachine; +use super::authority::{ + AuthorityError, AuthoritySession, CreateTransactionAuthorityRequest, ProductAuthority, + SignPayloadAuthorityRequest, SignRawAuthorityRequest, authority_session, + require_current_session, +}; +use super::connected_session_ui_info; +use super::identity::resolve_session_identity_with_chain; +use super::services::RuntimeServices; +use super::sso_pairing::{SsoPairingFlow, SsoPairingOutcome}; +use super::sso_remote::{SSO_PEER_DISCONNECT_REASON, SessionDisconnects, SsoSessionKey}; +use super::statement_store_rpc::StatementStoreRpc; +use crate::chain_runtime::ChainRuntime; +use crate::host_logic::entropy::derive_product_entropy_from_source; +use crate::host_logic::session::{SessionInfo, SessionState, encode_persisted_session}; +use crate::host_logic::session_store::SessionStoreChangeNotifier; +use crate::subscription::Spawner; + +use futures::StreamExt; +use tracing::instrument; +use truapi::versioned::account::{HostRequestLoginError, HostRequestLoginResponse}; +use truapi::{CallContext, CallError, v01}; +use truapi_platform::{CoreStorageKey, PairingHostConfig, Platform, ProductContext}; + +/// Distinguishes all remote authority request entrypoints by wire label. +#[derive(Clone, Copy, Debug, derive_more::Display)] +pub(super) enum AuthorityRequestKind { + #[display("sign-payload")] + SignPayload, + #[display("sign-raw")] + SignRaw, + #[display("create-transaction")] + CreateTransaction, + #[display("legacy-sign-payload")] + LegacySignPayload, + #[display("legacy-sign-raw")] + LegacySignRaw, + #[display("legacy-create-transaction")] + LegacyCreateTransaction, +} + +impl From<&SignPayloadAuthorityRequest> for AuthorityRequestKind { + fn from(request: &SignPayloadAuthorityRequest) -> Self { + match request { + SignPayloadAuthorityRequest::Product(_) => Self::SignPayload, + SignPayloadAuthorityRequest::LegacyAccount { .. } => Self::LegacySignPayload, + } + } +} + +impl From<&SignRawAuthorityRequest> for AuthorityRequestKind { + fn from(request: &SignRawAuthorityRequest) -> Self { + match request { + SignRawAuthorityRequest::Product(_) => Self::SignRaw, + SignRawAuthorityRequest::LegacyAccount { .. } => Self::LegacySignRaw, + } + } +} + +impl From<&CreateTransactionAuthorityRequest> for AuthorityRequestKind { + fn from(request: &CreateTransactionAuthorityRequest) -> Self { + match request { + CreateTransactionAuthorityRequest::Product(_) => Self::CreateTransaction, + CreateTransactionAuthorityRequest::LegacyAccount { .. } => { + Self::LegacyCreateTransaction + } + } + } +} + +struct LoginInFlight { + waiters: Vec>>, +} + +struct LoginInFlightOwner<'a> { + host: &'a PairingHost, + active: bool, +} + +impl<'a> LoginInFlightOwner<'a> { + fn new(host: &'a PairingHost) -> Self { + Self { host, active: true } + } + + fn finish(&mut self, result: Result<(), String>) { + if self.active { + self.active = false; + self.host.finish_login_in_flight(result); + } + } +} + +impl Drop for LoginInFlightOwner<'_> { + fn drop(&mut self) { + if self.active { + self.host + .finish_login_in_flight(Err("login request aborted".to_string())); + } + } +} + +/// Remote account authority for a pairing host. +pub(crate) struct PairingHost { + pub(super) platform: Arc, + pub(super) host_config: PairingHostConfig, + pub(super) chain: ChainRuntime, + /// Active inter-host session with a signing host. + session_state: Arc, + session_store_changes: Arc, + pub(super) auth_state: AuthStateMachine, + pub(super) statement_store: StatementStoreRpc, + session_disconnects: Arc, + disconnect_monitor: Mutex>, + login_in_flight: Mutex>, + /// Self-reference captured by the spawned disconnect-monitor task. + weak_self: Weak, + pub(super) spawner: Spawner, +} + +impl PairingHost { + pub(crate) fn new(services: Arc, host_config: PairingHostConfig) -> Arc { + let platform = services.platform.clone(); + let auth_state = AuthStateMachine::new(platform.clone()); + Arc::new_cyclic(|weak_self| Self { + platform, + host_config, + chain: services.chain.clone(), + session_state: SessionState::new(), + session_store_changes: SessionStoreChangeNotifier::new(), + auth_state, + statement_store: services.statement_store.clone(), + session_disconnects: Arc::new(SessionDisconnects::default()), + disconnect_monitor: Mutex::new(None), + login_in_flight: Mutex::new(None), + weak_self: weak_self.clone(), + spawner: services.spawner.clone(), + }) + } + + pub(crate) fn session_state(&self) -> Arc { + self.session_state.clone() + } + + pub(crate) fn notify_session_store_changed(&self) { + self.session_store_changes.notify(); + } + + #[cfg(test)] + pub(crate) fn start_session_store_sync_for_tests(self: Arc, spawner: Spawner) { + self.start_session_store_sync(spawner); + } + + #[cfg(test)] + pub(crate) fn start_session_supervision_for_current_session(&self) { + self.start_remote_monitor_for_current_session(); + } + + fn current_session(&self) -> Option { + self.session_state.current().as_ref().map(authority_session) + } + + #[cfg(test)] + pub(crate) fn start_remote_monitor_for_current_session(&self) { + if let Some(session) = self.session_state.current() { + self.start_disconnect_monitor(&session); + } + } + + #[instrument(skip_all, fields(runtime.method = "session_store.sync"))] + pub(crate) fn start_session_store_sync(self: Arc, spawner: Spawner) { + let pairing_host = Arc::downgrade(&self); + spawner(Box::pin(async move { + let Some(current) = pairing_host.upgrade() else { + return; + }; + let mut ticks = current.session_store_changes.subscribe(); + drop(current); + // Clearing the store can itself notify this subscription; clear at + // most once per read-error streak so a persistently failing read + // cannot spin the loop through its own clear notifications. + let mut cleared_after_read_error = false; + while ticks.next().await.is_some() { + let Some(pairing_host) = pairing_host.upgrade() else { + break; + }; + match pairing_host + .platform + .read_core_storage(CoreStorageKey::AuthSession) + .await + { + Ok(Some(blob)) => { + cleared_after_read_error = false; + match crate::host_logic::session::decode_persisted_session(&blob) { + Ok(session) => { + let resolved = resolve_session_identity_with_chain( + &pairing_host.chain, + pairing_host.host_config.people_chain_genesis_hash, + session, + ) + .await; + if encode_persisted_session(&resolved) != blob { + let _ = pairing_host + .platform + .write_core_storage( + CoreStorageKey::AuthSession, + encode_persisted_session(&resolved), + ) + .await; + } + pairing_host.set_connected_session(resolved); + } + Err(_) => { + pairing_host.clear_disconnected_session(true, false).await; + } + } + } + Ok(None) => { + cleared_after_read_error = false; + pairing_host.clear_disconnected_session(false, false).await; + } + Err(_) => { + pairing_host.clear_disconnected_session(false, false).await; + if !cleared_after_read_error { + cleared_after_read_error = true; + let _ = pairing_host + .platform + .clear_core_storage(CoreStorageKey::AuthSession) + .await; + } + } + } + } + })); + } + + #[instrument(skip_all, fields(runtime.method = "account.request_login", product = %product.product_id))] + async fn request_login( + &self, + product: &ProductContext, + ) -> Result> { + let _ = product; + if let Some(session) = self.session_state.current() { + self.auth_state + .connected(&connected_session_ui_info(&session)); + return Ok(HostRequestLoginResponse::V1( + v01::HostRequestLoginResponse::AlreadyConnected, + )); + } + + if let Some(waiter) = self.login_waiter() { + match waiter.await { + Ok(Ok(())) => { + return Ok(HostRequestLoginResponse::V1( + if self.session_state.current().is_some() { + v01::HostRequestLoginResponse::AlreadyConnected + } else { + v01::HostRequestLoginResponse::Rejected + }, + )); + } + Ok(Err(reason)) => { + return Err(CallError::Domain(HostRequestLoginError::V1( + v01::HostRequestLoginError::Unknown { reason }, + ))); + } + Err(_) => { + return Err(CallError::Domain(HostRequestLoginError::V1( + v01::HostRequestLoginError::Unknown { + reason: "login waiter dropped".to_string(), + }, + ))); + } + } + } + + let mut login_owner = LoginInFlightOwner::new(self); + let outcome = match SsoPairingFlow::new(self).request_session().await { + Ok(outcome) => outcome, + Err(err) => { + login_owner.finish(Err(login_error_reason(&err))); + return Err(err); + } + }; + match outcome { + SsoPairingOutcome::Cancelled => { + login_owner.finish(Ok(())); + if self.session_state.current().is_some() { + Ok(HostRequestLoginResponse::V1( + v01::HostRequestLoginResponse::AlreadyConnected, + )) + } else { + Ok(HostRequestLoginResponse::V1( + v01::HostRequestLoginResponse::Rejected, + )) + } + } + SsoPairingOutcome::Success(session) => { + self.set_connected_session(*session); + login_owner.finish(Ok(())); + Ok(HostRequestLoginResponse::V1( + v01::HostRequestLoginResponse::Success, + )) + } + } + } + + #[instrument(skip_all, fields(runtime.method = "account.disconnect"))] + async fn disconnect(&self) { + self.cancel_login(); + let session = self.session_state.current(); + self.clear_disconnected_session(true, true).await; + if let Some(session) = session.as_ref() { + let _ = self.submit_disconnected_message(session).await; + } + } + + #[instrument(skip_all, fields(runtime.method = "account.cancel_login"))] + pub(crate) fn cancel_login(&self) { + self.auth_state.login_cancelled(); + } + + fn login_waiter(&self) -> Option>> { + let mut in_flight = self + .login_in_flight + .lock() + .expect("login in-flight mutex poisoned"); + if let Some(in_flight) = in_flight.as_mut() { + let (tx, rx) = oneshot::channel(); + in_flight.waiters.push(tx); + Some(rx) + } else { + *in_flight = Some(LoginInFlight { + waiters: Vec::new(), + }); + None + } + } + + fn finish_login_in_flight(&self, result: Result<(), String>) { + let waiters = self + .login_in_flight + .lock() + .expect("login in-flight mutex poisoned") + .take() + .map(|in_flight| in_flight.waiters) + .unwrap_or_default(); + for waiter in waiters { + let _ = waiter.send(result.clone()); + } + } + + #[instrument(skip_all, fields(runtime.method = "session_store.clear_disconnected"))] + async fn clear_disconnected_session( + &self, + clear_auth_session: bool, + clear_pairing_identity: bool, + ) { + let previous = self.session_state.current(); + self.session_state.clear_session(); + self.stop_session_channel(previous.as_ref()); + if clear_auth_session { + let _ = self + .platform + .clear_core_storage(CoreStorageKey::AuthSession) + .await; + } + self.auth_state.store_disconnected(); + if clear_pairing_identity { + let _ = self + .platform + .clear_core_storage(CoreStorageKey::PairingDeviceIdentity) + .await; + } + } + + fn set_connected_session(&self, session: SessionInfo) { + let previous = self.session_state.current(); + self.session_state.set_session(session.clone()); + if previous.as_ref() != Some(&session) { + self.stop_session_channel(previous.as_ref()); + } + self.start_disconnect_monitor(&session); + self.auth_state + .connected(&connected_session_ui_info(&session)); + } + + /// Single funnel for peer-initiated disconnects. Every detection source + /// (monitor task, request-path error) must route here: it wakes in-flight + /// waiters for `key`, then clears the session when `key` is still current, + /// so stale notifications for replaced sessions only wake their own + /// waiters. + async fn handle_signing_host_disconnected(&self, key: SsoSessionKey) { + self.session_disconnects + .notify_key(key, SSO_PEER_DISCONNECT_REASON); + if !self.current_sso_session_matches(key) { + return; + } + + self.clear_disconnected_session(true, true).await; + } + + fn current_sso_session_matches(&self, key: SsoSessionKey) -> bool { + sso_channel::session_matches_key(&self.session_state, key) + } + + fn current_private_session( + &self, + session: &AuthoritySession, + ) -> Result { + require_current_session(&self.session_state, session) + } + + async fn sign_payload( + &self, + cx: &CallContext, + session: &AuthoritySession, + request: SignPayloadAuthorityRequest, + ) -> Result { + let session = self.current_private_session(session)?; + self.remote_sign_payload(cx, &session, request).await + } + + async fn sign_raw( + &self, + cx: &CallContext, + session: &AuthoritySession, + request: SignRawAuthorityRequest, + ) -> Result { + let session = self.current_private_session(session)?; + self.remote_sign_raw(cx, &session, request).await + } + + async fn create_transaction( + &self, + cx: &CallContext, + session: &AuthoritySession, + request: CreateTransactionAuthorityRequest, + ) -> Result { + let session = self.current_private_session(session)?; + self.remote_create_transaction(cx, &session, request).await + } + + async fn account_alias( + &self, + cx: &CallContext, + session: &AuthoritySession, + product_account_id: v01::ProductAccountId, + requesting_product_id: String, + ) -> Result { + let session = self.current_private_session(session)?; + self.remote_account_alias(cx, &session, product_account_id, requesting_product_id) + .await + } + + async fn allocate_resources( + &self, + cx: &CallContext, + session: &AuthoritySession, + product_id: String, + request: v01::HostRequestResourceAllocationRequest, + ) -> Result { + let session = self.current_private_session(session)?; + self.remote_allocate_resources(cx, &session, product_id, request) + .await + } + + fn derive_entropy( + &self, + session: &AuthoritySession, + product_id: &str, + context: &[u8], + ) -> Result<[u8; 32], AuthorityError> { + let session = self.current_private_session(session)?; + if session.sso.is_none() { + return Err(AuthorityError::Disconnected); + } + let root_entropy_source = + session + .root_entropy_source + .ok_or_else(|| AuthorityError::Unavailable { + reason: "Session secret missing".to_string(), + })?; + derive_product_entropy_from_source(&root_entropy_source, product_id, context).map_err( + |err| AuthorityError::Unknown { + reason: err.to_string(), + }, + ) + } +} + +fn login_error_reason(err: &CallError) -> String { + match err { + CallError::Domain(HostRequestLoginError::V1(v01::HostRequestLoginError::Unknown { + reason, + })) + | CallError::HostFailure { reason } => reason.clone(), + CallError::Unsupported => "login unsupported".to_string(), + CallError::Denied => "login denied".to_string(), + CallError::MalformedFrame { reason } => reason.clone(), + } +} + +#[async_trait::async_trait] +impl ProductAuthority for PairingHost { + fn current_session(&self) -> Option { + PairingHost::current_session(self) + } + + fn session_state(&self) -> Arc { + PairingHost::session_state(self) + } + + async fn request_login( + &self, + product: &ProductContext, + ) -> Result> { + PairingHost::request_login(self, product).await + } + + async fn disconnect(&self) { + PairingHost::disconnect(self).await; + } + + async fn sign_payload( + &self, + cx: &CallContext, + session: &AuthoritySession, + request: SignPayloadAuthorityRequest, + ) -> Result { + PairingHost::sign_payload(self, cx, session, request).await + } + + async fn sign_raw( + &self, + cx: &CallContext, + session: &AuthoritySession, + request: SignRawAuthorityRequest, + ) -> Result { + PairingHost::sign_raw(self, cx, session, request).await + } + + async fn create_transaction( + &self, + cx: &CallContext, + session: &AuthoritySession, + request: CreateTransactionAuthorityRequest, + ) -> Result { + PairingHost::create_transaction(self, cx, session, request).await + } + + async fn account_alias( + &self, + cx: &CallContext, + session: &AuthoritySession, + product_account_id: v01::ProductAccountId, + requesting_product_id: String, + ) -> Result { + PairingHost::account_alias(self, cx, session, product_account_id, requesting_product_id) + .await + } + + async fn allocate_resources( + &self, + cx: &CallContext, + session: &AuthoritySession, + product_id: String, + request: v01::HostRequestResourceAllocationRequest, + ) -> Result { + PairingHost::allocate_resources(self, cx, session, product_id, request).await + } + + fn derive_entropy( + &self, + session: &AuthoritySession, + product_id: &str, + context: &[u8], + ) -> Result<[u8; 32], AuthorityError> { + PairingHost::derive_entropy(self, session, product_id, context) + } +} diff --git a/rust/crates/truapi-server/src/runtime/pairing_host/sso_channel.rs b/rust/crates/truapi-server/src/runtime/pairing_host/sso_channel.rs new file mode 100644 index 00000000..05831642 --- /dev/null +++ b/rust/crates/truapi-server/src/runtime/pairing_host/sso_channel.rs @@ -0,0 +1,471 @@ +//! SSO statement-store channel to the paired remote signing host. + +use super::super::authority::{ + AuthorityCancelError, AuthorityError, CreateTransactionAuthorityRequest, + SignPayloadAuthorityRequest, SignRawAuthorityRequest, +}; +use super::super::sso_remote::{ + RemoteResponseWait, SSO_LOCAL_DISCONNECT_REASON, SSO_PEER_DISCONNECT_REASON, + SsoRemoteResponseError, SsoSessionKey, fresh_statement_expiry, sso_message_id, + statement_subscription_stream, subscribe_statement_topic, wait_for_sso_remote_response, +}; +use super::super::statement_store_rpc::{self, StatementStoreRpc}; +use super::AuthorityRequestKind; +use super::PairingHost; +use crate::host_logic::session::{SessionInfo, SessionState, SsoSessionInfo}; +use crate::host_logic::sso::messages::{ + OnExistingAllowancePolicy, RemoteMessage, RemoteMessageData, SsoAllocationOutcome, + SsoRemoteResponse, SsoSessionStatement, alias_request_message, + build_outgoing_request_statement, create_transaction_message, decode_sso_session_statement, + resource_allocation_message, sign_payload_message, sign_raw_message, v1, +}; +use crate::host_logic::statement_store::parse_new_statements_result; + +use futures::FutureExt; +use futures::future::{AbortHandle, Abortable}; +use tracing::{debug, instrument, warn}; +use truapi::{CallContext, latest}; + +const UNEXPECTED_SSO_SIGNING_RESPONSE: &str = "Unexpected SSO response for signing request"; +const UNEXPECTED_SSO_TRANSACTION_RESPONSE: &str = "Unexpected SSO response for transaction request"; + +#[derive(Clone, Copy, Debug, derive_more::Display)] +enum RemoteAction { + #[display("{_0}")] + Signing(AuthorityRequestKind), + #[display("account-alias")] + AccountAlias, + #[display("resource-allocation")] + ResourceAllocation, +} + +/// Active peer-disconnect watcher for one SSO session; aborts on drop. +pub(super) struct SsoDisconnectMonitor { + key: SsoSessionKey, + abort: AbortHandle, +} + +impl Drop for SsoDisconnectMonitor { + fn drop(&mut self) { + self.abort.abort(); + } +} + +impl PairingHost { + async fn submit_sign_request( + &self, + cx: &CallContext, + session: &SessionInfo, + action: AuthorityRequestKind, + message: RemoteMessage, + ) -> Result { + let response = self + .submit_remote_message(cx, session, RemoteAction::Signing(action), message) + .await?; + let SsoRemoteResponse::Sign(response) = response else { + return Err(SsoRemoteResponseError::Failure( + UNEXPECTED_SSO_SIGNING_RESPONSE.to_string(), + )); + }; + response + .payload + .map(|payload| latest::HostSignPayloadResponse { + signature: payload.signature, + signed_transaction: payload.signed_transaction, + }) + .map_err(SsoRemoteResponseError::Failure) + } + + fn stop_disconnect_monitor(&self) { + self.disconnect_monitor + .lock() + .expect("SSO disconnect monitor mutex poisoned") + .take(); + } + + pub(super) fn start_disconnect_monitor(&self, session: &SessionInfo) { + let Some(sso) = session.sso.clone() else { + self.stop_disconnect_monitor(); + return; + }; + let key = SsoSessionKey::from_session(&sso); + + let (registration, spawner) = { + let mut current = self + .disconnect_monitor + .lock() + .expect("SSO disconnect monitor mutex poisoned"); + if current.as_ref().is_some_and(|active| active.key == key) { + return; + } + let (abort, registration) = AbortHandle::new_pair(); + *current = Some(SsoDisconnectMonitor { key, abort }); + (registration, self.spawner.clone()) + }; + + let statement_store = self.statement_store.clone(); + let pairing_host = self.weak_self.clone(); + let future = async move { + let result = wait_for_sso_peer_disconnect(statement_store, sso).await; + let Some(pairing_host) = pairing_host.upgrade() else { + return; + }; + { + let mut active = pairing_host + .disconnect_monitor + .lock() + .expect("SSO disconnect monitor mutex poisoned"); + if active.as_ref().is_some_and(|active| active.key == key) { + *active = None; + } + } + match result { + Ok(()) => { + pairing_host.handle_signing_host_disconnected(key).await; + } + Err(reason) => { + warn!(%reason, "SSO peer disconnect monitor stopped"); + } + } + }; + spawner(Box::pin(Abortable::new(future, registration).map(|_| ()))); + } + + /// Stop channel work for a cleared session: wake its in-flight waiters + /// with a local disconnect, then drop the peer-disconnect monitor. + pub(super) fn stop_session_channel(&self, session: Option<&SessionInfo>) { + if let Some(sso) = session.and_then(|session| session.sso.as_ref()) { + self.session_disconnects + .notify(sso, SSO_LOCAL_DISCONNECT_REASON); + } + self.stop_disconnect_monitor(); + } + + /// Best-effort `Disconnected` notification to the SSO peer. + #[instrument(skip_all, fields(runtime.method = "sso.disconnect.submit"))] + pub(super) async fn submit_disconnected_message( + &self, + session: &SessionInfo, + ) -> Result<(), String> { + let sso = session + .sso + .as_ref() + .ok_or_else(|| "No SSO session state".to_string())?; + let message_id = "truapi:sso:disconnect".to_string(); + let message = RemoteMessage { + message_id: message_id.clone(), + data: RemoteMessageData::V1(v1::RemoteMessage::Disconnected), + }; + let statement = build_outgoing_request_statement( + sso, + message_id, + vec![message], + fresh_statement_expiry(), + )?; + self.statement_store + .submit_fire_and_forget(statement, "SSO statement-store") + .await + .map_err(|err| format!("SSO statement submit failed: {err}"))?; + Ok(()) + } + + /// Submit an SSO remote message and wait for the signing-host response. + #[instrument(skip_all, fields(runtime.method = "sso.remote_message.submit", action = %action))] + async fn submit_remote_message( + &self, + cx: &CallContext, + session: &SessionInfo, + action: RemoteAction, + message: RemoteMessage, + ) -> Result { + let sso = session + .sso + .as_ref() + .ok_or_else(|| SsoRemoteResponseError::Failure("No SSO session state".to_string()))?; + let key = SsoSessionKey::from_session(sso); + let (_disconnect_guard, disconnect) = self.session_disconnects.subscribe(sso); + if !session_matches_key(&self.session_state, key) { + return Err(SsoRemoteResponseError::LocalDisconnected); + } + let message_id = sso_message_id(cx, action); + let statement = build_outgoing_request_statement( + sso, + message_id.clone(), + vec![message], + fresh_statement_expiry(), + ) + .map_err(SsoRemoteResponseError::Failure)?; + let rpc_client = self.statement_store.client("SSO statement-store").await?; + let own_subscription = subscribe_statement_topic(&rpc_client, sso.session_id_own) + .await + .map_err(|err| { + SsoRemoteResponseError::Failure(format!( + "SSO own statement-store subscribe failed: {err}" + )) + })?; + let peer_subscription = subscribe_statement_topic(&rpc_client, sso.session_id_peer) + .await + .map_err(|err| { + SsoRemoteResponseError::Failure(format!( + "SSO peer statement-store subscribe failed: {err}" + )) + })?; + let submit_client = rpc_client.clone(); + let session_state = self.session_state.clone(); + let submit = async move { + if !session_matches_key(&session_state, key) { + return Err(SsoRemoteResponseError::LocalDisconnected); + } + statement_store_rpc::submit(&submit_client, statement) + .await + .map_err(|err| { + SsoRemoteResponseError::Failure(format!("SSO statement submit failed: {err}")) + }) + } + .boxed(); + let action = action.to_string(); + debug!(action, %message_id, "submitted SSO remote message, awaiting response"); + let result = wait_for_sso_remote_response(RemoteResponseWait { + own_statements: statement_subscription_stream(own_subscription, "own"), + peer_statements: statement_subscription_stream(peer_subscription, "peer"), + submit, + session: sso, + statement_request_id: &message_id, + remote_message_id: &message_id, + cancel: cx.cancel(), + disconnect: Some(disconnect), + }) + .await; + match &result { + Ok(_) => debug!(action, %message_id, "SSO remote response received"), + Err(reason) => warn!(action, %message_id, %reason, "SSO remote message failed"), + } + if matches!(&result, Err(SsoRemoteResponseError::PeerDisconnected)) { + self.handle_signing_host_disconnected(key).await; + } + result + } + + pub(super) async fn remote_sign_payload( + &self, + cx: &CallContext, + session: &SessionInfo, + request: SignPayloadAuthorityRequest, + ) -> Result { + let action = AuthorityRequestKind::from(&request); + let message_id = sso_message_id(cx, RemoteAction::Signing(action)); + let request = match request { + SignPayloadAuthorityRequest::Product(request) => request, + SignPayloadAuthorityRequest::LegacyAccount { + product_account, + request, + } => latest::HostSignPayloadRequest { + account: product_account, + payload: request.payload, + }, + }; + let message = sign_payload_message(message_id, request); + self.submit_sign_request(cx, session, action, message) + .await + .map_err(remote_authority_error) + } + + pub(super) async fn remote_sign_raw( + &self, + cx: &CallContext, + session: &SessionInfo, + request: SignRawAuthorityRequest, + ) -> Result { + let action = AuthorityRequestKind::from(&request); + let message_id = sso_message_id(cx, RemoteAction::Signing(action)); + let request = match request { + SignRawAuthorityRequest::Product(request) => request, + SignRawAuthorityRequest::LegacyAccount { + product_account, + request, + } => latest::HostSignRawRequest { + account: product_account, + payload: request.payload, + }, + }; + let message = sign_raw_message(message_id, request); + let response = self + .submit_remote_message(cx, session, RemoteAction::Signing(action), message) + .await + .map_err(remote_authority_error)?; + let SsoRemoteResponse::Sign(response) = response else { + return Err(AuthorityError::Unknown { + reason: UNEXPECTED_SSO_SIGNING_RESPONSE.to_string(), + }); + }; + response + .payload + .map(|payload| latest::HostSignPayloadResponse { + signature: payload.signature, + signed_transaction: payload.signed_transaction, + }) + .map_err(remote_authority_error) + } + + pub(super) async fn remote_create_transaction( + &self, + cx: &CallContext, + session: &SessionInfo, + request: CreateTransactionAuthorityRequest, + ) -> Result { + let action = AuthorityRequestKind::from(&request); + let message_id = sso_message_id(cx, RemoteAction::Signing(action)); + let request = match request { + CreateTransactionAuthorityRequest::Product(request) => request, + CreateTransactionAuthorityRequest::LegacyAccount { + product_account, + request, + } => latest::ProductAccountTxPayload { + signer: product_account, + genesis_hash: request.genesis_hash, + call_data: request.call_data, + extensions: request.extensions, + tx_ext_version: request.tx_ext_version, + }, + }; + let message = create_transaction_message(message_id, request); + let response = self + .submit_remote_message(cx, session, RemoteAction::Signing(action), message) + .await + .map_err(remote_authority_error)?; + let SsoRemoteResponse::CreateTransaction(response) = response else { + return Err(AuthorityError::Unknown { + reason: UNEXPECTED_SSO_TRANSACTION_RESPONSE.to_string(), + }); + }; + response + .signed_transaction + .map(|transaction| latest::HostCreateTransactionResponse { transaction }) + .map_err(remote_authority_error) + } + + pub(super) async fn remote_account_alias( + &self, + cx: &CallContext, + session: &SessionInfo, + product_account_id: latest::ProductAccountId, + requesting_product_id: String, + ) -> Result { + let message_id = sso_message_id(cx, RemoteAction::AccountAlias); + let message = alias_request_message( + message_id.clone(), + product_account_id, + requesting_product_id, + ); + let response = self + .submit_remote_message(cx, session, RemoteAction::AccountAlias, message) + .await + .map_err(remote_authority_error)?; + let SsoRemoteResponse::RingVrfAlias(response) = response else { + return Err(AuthorityError::Unknown { + reason: "Unexpected SSO response for account alias request".to_string(), + }); + }; + response.payload.map_err(remote_authority_error) + } + + pub(super) async fn remote_allocate_resources( + &self, + cx: &CallContext, + session: &SessionInfo, + product_id: String, + request: latest::HostRequestResourceAllocationRequest, + ) -> Result { + let message_id = sso_message_id(cx, RemoteAction::ResourceAllocation); + let message = resource_allocation_message( + message_id, + product_id, + request.resources, + OnExistingAllowancePolicy::Increase, + ); + let response = self + .submit_remote_message(cx, session, RemoteAction::ResourceAllocation, message) + .await + .map_err(remote_authority_error)?; + let SsoRemoteResponse::ResourceAllocation(response) = response else { + return Err(AuthorityError::Unknown { + reason: "Unexpected SSO response for resource allocation request".to_string(), + }); + }; + response + .payload + .map(|outcomes| latest::HostRequestResourceAllocationResponse { + outcomes: outcomes.into_iter().map(Into::into).collect(), + }) + .map_err(remote_authority_error) + } +} + +/// True when the current session's SSO channel matches `key`. +pub(super) fn session_matches_key(session_state: &SessionState, key: SsoSessionKey) -> bool { + session_state.current().as_ref().is_some_and(|current| { + current + .sso + .as_ref() + .is_some_and(|sso| SsoSessionKey::from_session(sso) == key) + }) +} + +fn remote_authority_error(reason: impl Into) -> AuthorityError { + match reason.into() { + SsoRemoteResponseError::Cancelled(err) => AuthorityError::Cancelled( + AuthorityCancelError::new(err.remote_message_id(), err.reason()), + ), + SsoRemoteResponseError::LocalDisconnected | SsoRemoteResponseError::PeerDisconnected => { + AuthorityError::Disconnected + } + SsoRemoteResponseError::Failure(reason) => match reason.as_str() { + "Rejected" | "User rejected" => AuthorityError::Rejected, + SSO_LOCAL_DISCONNECT_REASON | SSO_PEER_DISCONNECT_REASON => { + AuthorityError::Disconnected + } + _ => AuthorityError::Unknown { reason }, + }, + } +} + +#[instrument(skip_all, fields(runtime.method = "sso.peer_disconnect.monitor"))] +async fn wait_for_sso_peer_disconnect( + statement_store: StatementStoreRpc, + session: SsoSessionInfo, +) -> Result<(), String> { + let rpc_client = statement_store.client("SSO disconnect monitor").await?; + let mut subscription = + statement_store_rpc::subscribe_match_all(&rpc_client, &[session.session_id_peer]) + .await + .map_err(|err| format!("SSO disconnect monitor subscribe failed: {err}"))?; + while let Some(item) = subscription.next().await { + let value = item.map_err(|err| format!("SSO disconnect monitor item failed: {err}"))?; + let page = parse_new_statements_result("sso-peer-disconnect-monitor".to_string(), &value) + .map_err(|err| err.to_string())?; + for statement in page.statements { + if matches!( + decode_sso_session_statement( + &session, + &statement, + "truapi:sso-peer-disconnect-monitor", + "truapi:sso-peer-disconnect-monitor", + )?, + Some(SsoSessionStatement::Disconnected) + ) { + return Ok(()); + } + } + } + Err("SSO disconnect monitor response stream ended".to_string()) +} + +impl From for latest::AllocationOutcome { + fn from(outcome: SsoAllocationOutcome) -> Self { + match outcome { + SsoAllocationOutcome::Allocated(_) => Self::Allocated, + SsoAllocationOutcome::Rejected => Self::Rejected, + SsoAllocationOutcome::NotAvailable => Self::NotAvailable, + } + } +} diff --git a/rust/crates/truapi-server/src/runtime/services.rs b/rust/crates/truapi-server/src/runtime/services.rs new file mode 100644 index 00000000..97511af5 --- /dev/null +++ b/rust/crates/truapi-server/src/runtime/services.rs @@ -0,0 +1,77 @@ +//! Role-neutral runtime services shared by product-facing runtimes. +//! +//! This module owns only infrastructure that is valid for both pairing hosts +//! and signing hosts. Pairing state, signing state, active sessions, and role +//! controls live on the concrete role objects. + +use std::sync::Arc; +use std::sync::atomic::{AtomicU64, Ordering}; + +use crate::chain_runtime::{ChainRuntime, RuntimeChainProvider, RuntimeFailure}; +use crate::runtime::statement_store_rpc::StatementStoreRpc; +use crate::subscription::Spawner; +use async_trait::async_trait; +use truapi_platform::{JsonRpcConnection, Platform}; + +/// Infrastructure shared by all product runtimes created from one host role. +pub(crate) struct RuntimeServices { + pub(crate) platform: Arc, + pub(crate) chain: ChainRuntime, + pub(crate) statement_store: StatementStoreRpc, + pub(crate) spawner: Spawner, + next_core_instance: AtomicU64, +} + +impl RuntimeServices { + /// Build role-neutral runtime services from the platform and People-chain + /// genesis hash used by statement-store backed protocols. + pub(crate) fn new( + platform: Arc, + people_chain_genesis_hash: [u8; 32], + spawner: Spawner, + ) -> Arc { + let chain_provider = Arc::new(HostChainProvider { + platform: platform.clone(), + }); + let chain = ChainRuntime::new(chain_provider, spawner.clone()); + let statement_store = + StatementStoreRpc::new(platform.clone(), people_chain_genesis_hash, spawner.clone()); + Arc::new(Self { + platform, + chain, + statement_store, + spawner, + next_core_instance: AtomicU64::new(1), + }) + } + + pub(crate) fn next_core_instance(&self) -> u64 { + self.next_core_instance.fetch_add(1, Ordering::Relaxed) + } +} + +/// Adapter from `truapi_platform::ChainProvider` into the +/// [`RuntimeChainProvider`] surface the chain runtime expects. +struct HostChainProvider { + platform: Arc, +} + +#[async_trait] +impl RuntimeChainProvider for HostChainProvider { + async fn connect( + &self, + genesis_hash: Vec, + ) -> Result, RuntimeFailure> { + let genesis_hash: [u8; 32] = genesis_hash.try_into().map_err(|genesis_hash: Vec| { + RuntimeFailure::host_failure( + "remote_chain_connect", + format!("genesis_hash must be 32 bytes, got {}", genesis_hash.len()), + ) + })?; + self.platform + .connect(genesis_hash) + .await + .map(Arc::from) + .map_err(|_| RuntimeFailure::unavailable("remote_chain_connect")) + } +} diff --git a/rust/crates/truapi-server/src/runtime/signing_host.rs b/rust/crates/truapi-server/src/runtime/signing_host.rs new file mode 100644 index 00000000..8cd58769 --- /dev/null +++ b/rust/crates/truapi-server/src/runtime/signing_host.rs @@ -0,0 +1,610 @@ +//! Signing-host role for wallet-local account authority. +//! +//! A signing host owns the user's keys and serves authority requests locally, +//! with no pairing flow and no SSO channel. Secret material is provided by the +//! embedding host at unlock through [`LocalActivation::activate_local_session`] +//! (the host owns its persistence, e.g. the OS keychain) and kept in memory +//! for the session, zeroized on disconnect. + +mod local_activation; + +use std::sync::{Arc, Mutex}; + +pub(crate) use local_activation::LocalActivation; + +use super::authority::{ + AuthorityError, AuthoritySession, CreateTransactionAuthorityRequest, ProductAuthority, + SignPayloadAuthorityRequest, SignRawAuthorityRequest, authority_session, + require_current_session, +}; +use super::connected_session_ui_info; +use crate::host_logic::entropy::derive_product_entropy; +use crate::host_logic::product_account::{ + ProductAccountError, SR25519_SIGNING_CONTEXT, derive_product_keypair, + derive_root_keypair_from_entropy, +}; +use crate::host_logic::session::SessionState; +use crate::runtime::auth_state::AuthStateMachine; + +use truapi::versioned::account::{HostRequestLoginError, HostRequestLoginResponse}; +use truapi::{CallContext, CallError, v01}; +use truapi_platform::{Platform, ProductContext, normalize_product_identifier}; +use zeroize::Zeroizing; + +const BYTES_WRAP_PREFIX: &[u8] = b""; +const BYTES_WRAP_SUFFIX: &[u8] = b""; + +/// Wallet-local account authority for a signing host. +pub(crate) struct SigningHost { + session_state: Arc, + auth_state: AuthStateMachine, + /// Root BIP-39 entropy held only while a session is active. + root_entropy: Mutex>>>, +} + +impl SigningHost { + pub(crate) fn new(platform: Arc) -> Arc { + Arc::new(Self { + session_state: SessionState::new(), + auth_state: AuthStateMachine::new(platform), + root_entropy: Mutex::new(None), + }) + } + + pub(super) fn session_state(&self) -> Arc { + self.session_state.clone() + } + + /// Current root entropy, or [`AuthorityError::Disconnected`] when no local + /// session is active. + fn root_entropy(&self) -> Result>, AuthorityError> { + self.root_entropy + .lock() + .expect("signing host entropy mutex poisoned") + .clone() + .ok_or(AuthorityError::Disconnected) + } + + /// Derive the product-account keypair for `account` from the root entropy. + /// + /// The root keypair is recomputed per call (PBKDF2, 2048 rounds, via + /// `substrate-bip39`) rather than cached: the signing host holds only the + /// raw, zeroizable entropy, never an expanded secret key. + fn product_keypair( + &self, + account: &v01::ProductAccountId, + ) -> Result { + let entropy = self.root_entropy()?; + let root = derive_root_keypair_from_entropy(&entropy).map_err(product_authority_error)?; + let product_id = + normalize_product_identifier(&account.dot_ns_identifier).map_err(|err| { + AuthorityError::Unavailable { + reason: err.to_string(), + } + })?; + derive_product_keypair(&root, &product_id, account.derivation_index) + .map_err(product_authority_error) + } +} + +#[async_trait::async_trait] +impl ProductAuthority for SigningHost { + fn current_session(&self) -> Option { + self.session_state.current().as_ref().map(authority_session) + } + + fn session_state(&self) -> Arc { + SigningHost::session_state(self) + } + + async fn request_login( + &self, + _product: &ProductContext, + ) -> Result> { + if let Some(session) = self.session_state.current() { + self.auth_state + .connected(&connected_session_ui_info(&session)); + Ok(HostRequestLoginResponse::V1( + v01::HostRequestLoginResponse::AlreadyConnected, + )) + } else { + // The host activates a local session out of band once the wallet + // is unlocked; there is no in-core login prompt to drive. + Ok(HostRequestLoginResponse::V1( + v01::HostRequestLoginResponse::Rejected, + )) + } + } + + async fn disconnect(&self) { + self.root_entropy + .lock() + .expect("signing host entropy mutex poisoned") + .take(); + self.session_state.clear_session(); + self.auth_state.store_disconnected(); + } + + async fn sign_payload( + &self, + _cx: &CallContext, + _session: &AuthoritySession, + _request: SignPayloadAuthorityRequest, + ) -> Result { + Err(AuthorityError::Unavailable { + reason: "signing host: extrinsic-payload signing needs chain-metadata payload \ + assembly (not yet implemented)" + .to_string(), + }) + } + + async fn sign_raw( + &self, + _cx: &CallContext, + session: &AuthoritySession, + request: SignRawAuthorityRequest, + ) -> Result { + let SignRawAuthorityRequest::Product(request) = request else { + return Err(AuthorityError::Unavailable { + reason: "signing host: legacy-account raw signing is not yet implemented" + .to_string(), + }); + }; + require_current_session(&self.session_state, session)?; + let keypair = self.product_keypair(&request.account)?; + let message = raw_payload_bytes(request.payload)?; + let signature = keypair + .secret + .sign_simple(SR25519_SIGNING_CONTEXT, &message, &keypair.public) + .to_bytes(); + Ok(v01::HostSignPayloadResponse { + signature: signature.to_vec(), + signed_transaction: None, + }) + } + + async fn create_transaction( + &self, + _cx: &CallContext, + _session: &AuthoritySession, + _request: CreateTransactionAuthorityRequest, + ) -> Result { + Err(AuthorityError::Unavailable { + reason: "signing host: transaction construction needs chain metadata (not yet \ + implemented)" + .to_string(), + }) + } + + async fn account_alias( + &self, + _cx: &CallContext, + _session: &AuthoritySession, + _product_account_id: v01::ProductAccountId, + _requesting_product_id: String, + ) -> Result { + Err(AuthorityError::Unavailable { + reason: "signing host: ring-VRF alias derivation not yet implemented".to_string(), + }) + } + + async fn allocate_resources( + &self, + _cx: &CallContext, + _session: &AuthoritySession, + _product_id: String, + _request: v01::HostRequestResourceAllocationRequest, + ) -> Result { + Err(AuthorityError::Unavailable { + reason: "signing host: on-chain resource allocation not yet implemented".to_string(), + }) + } + + fn derive_entropy( + &self, + session: &AuthoritySession, + product_id: &str, + context: &[u8], + ) -> Result<[u8; 32], AuthorityError> { + require_current_session(&self.session_state, session)?; + let entropy = self.root_entropy()?; + derive_product_entropy(&entropy, product_id, context).map_err(|err| { + AuthorityError::Unknown { + reason: err.to_string(), + } + }) + } +} + +fn product_authority_error(err: ProductAccountError) -> AuthorityError { + AuthorityError::Unavailable { + reason: err.to_string(), + } +} + +/// Wrap raw sign-message bytes in the `` envelope unless +/// already wrapped, matching the polkadot-app raw-signing convention. +/// +/// String payloads follow the polkadot-app `isHex` rule: a `0x`-prefixed, +/// even-length string is decoded from hex, and a corrupt hex body is a hard +/// error (never silently signed as UTF-8); any other string is signed as its +/// UTF-8 bytes. +fn raw_payload_bytes(payload: v01::RawPayload) -> Result, AuthorityError> { + let raw = match payload { + v01::RawPayload::Bytes { bytes } => bytes, + v01::RawPayload::Payload { payload } => decode_payload_string(payload)?, + }; + if raw.starts_with(BYTES_WRAP_PREFIX) && raw.ends_with(BYTES_WRAP_SUFFIX) { + return Ok(raw); + } + let mut wrapped = + Vec::with_capacity(BYTES_WRAP_PREFIX.len() + raw.len() + BYTES_WRAP_SUFFIX.len()); + wrapped.extend_from_slice(BYTES_WRAP_PREFIX); + wrapped.extend_from_slice(&raw); + wrapped.extend_from_slice(BYTES_WRAP_SUFFIX); + Ok(wrapped) +} + +fn decode_payload_string(payload: String) -> Result, AuthorityError> { + // `isHex`: `0x` prefix and even total length. Odd length is not hex and is + // signed as UTF-8, matching polkadot-app. + if let Some(body) = payload + .strip_prefix("0x") + .filter(|_| payload.len().is_multiple_of(2)) + { + return hex::decode(body).map_err(|_| AuthorityError::Unknown { + reason: "raw sign payload is 0x-prefixed but not valid hex".to_string(), + }); + } + Ok(payload.into_bytes()) +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use super::super::authority::{AuthorityError, SignRawAuthorityRequest}; + use super::super::{ProductAuthority, ProductRuntimeHost, RuntimeServices, SigningHostRole}; + use super::{BYTES_WRAP_PREFIX, BYTES_WRAP_SUFFIX, LocalActivation, raw_payload_bytes}; + use crate::host_logic::product_account::{ + derive_product_keypair, derive_root_keypair_from_entropy, + }; + use crate::test_support::{StubPlatform, test_spawner}; + use truapi::api::{Account, Entropy, Signing}; + use truapi::versioned::account::{HostAccountGetError, HostAccountGetRequest}; + use truapi::versioned::entropy::HostDeriveEntropyRequest; + use truapi::versioned::signing::{HostSignRawError, HostSignRawRequest, HostSignRawResponse}; + use truapi::{CallContext, CallError, v01}; + use truapi_platform::{HostInfo, PlatformInfo, ProductContext, SigningHostConfig}; + + const ENTROPY: [u8; 16] = [0xAB; 16]; + + fn signing_runtime() -> (Arc, Arc) { + // Auto-confirm raw signing so the role-neutral confirmation gate does + // not reject before reaching the signing authority. + let platform: Arc = Arc::new(StubPlatform { + sign_raw_confirmed: true, + ..StubPlatform::default() + }); + let config = SigningHostConfig::new( + HostInfo { + name: "Polkadot Mobile".to_string(), + icon: None, + version: None, + }, + PlatformInfo::default(), + [0; 32], + ) + .expect("signing host config is valid"); + let services = RuntimeServices::new( + platform.clone(), + config.people_chain_genesis_hash, + test_spawner(), + ); + let signing_host = SigningHostRole::new(platform); + (services, signing_host) + } + + fn product_runtime( + services: Arc, + authority: Arc, + ) -> ProductRuntimeHost { + ProductRuntimeHost::from_services( + services, + authority, + ProductContext::new("myapp.dot".to_string()).expect("valid product id"), + ) + } + + fn product_runtime_for( + services: Arc, + authority: Arc, + product_id: &str, + ) -> ProductRuntimeHost { + ProductRuntimeHost::from_services( + services, + authority, + ProductContext::new(product_id.to_string()).expect("valid product id"), + ) + } + + #[test] + fn activate_then_sign_raw_verifies_against_derived_product_key() { + let (services, activation) = signing_runtime(); + futures::executor::block_on(activation.activate_local_session(ENTROPY.to_vec())) + .expect("activation succeeds"); + let runtime = product_runtime(services, activation); + let cx = CallContext::new(); + + let request = HostSignRawRequest::V1(v01::HostSignRawRequest { + account: v01::ProductAccountId { + dot_ns_identifier: "myapp.dot".to_string(), + derivation_index: 0, + }, + payload: v01::RawPayload::Bytes { + bytes: b"hello world".to_vec(), + }, + }); + let HostSignRawResponse::V1(response) = + futures::executor::block_on(runtime.sign_raw(&cx, request)).expect("sign_raw ok"); + assert!(response.signed_transaction.is_none()); + + let root = derive_root_keypair_from_entropy(&ENTROPY).unwrap(); + let keypair = derive_product_keypair(&root, "myapp.dot", 0).unwrap(); + let signature = + schnorrkel::Signature::from_bytes(&response.signature).expect("64-byte signature"); + assert!( + keypair + .public + .verify_simple(b"substrate", b"hello world", &signature) + .is_ok(), + "signature verifies over the -wrapped message", + ); + } + + #[test] + fn sign_raw_requires_active_session() { + let (services, authority) = signing_runtime(); + let runtime = product_runtime(services, authority); + let cx = CallContext::new(); + let request = HostSignRawRequest::V1(v01::HostSignRawRequest { + account: v01::ProductAccountId { + dot_ns_identifier: "myapp.dot".to_string(), + derivation_index: 0, + }, + payload: v01::RawPayload::Bytes { + bytes: vec![1, 2, 3], + }, + }); + let err = + futures::executor::block_on(runtime.sign_raw(&cx, request)).expect_err("no session"); + assert!(matches!(err, CallError::Domain(HostSignRawError::V1(_)))); + } + + #[test] + fn derive_entropy_matches_ios_vector_over_local_session() { + let (services, activation) = signing_runtime(); + futures::executor::block_on(activation.activate_local_session(ENTROPY.to_vec())) + .expect("activation succeeds"); + let runtime = product_runtime_for(services, activation, "test.product.dot"); + let cx = CallContext::new(); + let request = HostDeriveEntropyRequest::V1(v01::HostDeriveEntropyRequest { + context: b"my-key".to_vec(), + }); + let response = + futures::executor::block_on(runtime.derive(&cx, request)).expect("derive ok"); + let truapi::versioned::entropy::HostDeriveEntropyResponse::V1(inner) = response; + assert_eq!( + hex::encode(inner.entropy), + "479d5b9ecce19615397c9f160ee95e2f00c579837a5afb111132dd0da5fd472a", + ); + } + + #[test] + fn get_account_gates_on_local_session() { + let (services, authority) = signing_runtime(); + let runtime = product_runtime(services, authority); + let cx = CallContext::new(); + let request = HostAccountGetRequest::V1(v01::HostAccountGetRequest { + product_account_id: v01::ProductAccountId { + dot_ns_identifier: "myapp.dot".to_string(), + derivation_index: 0, + }, + }); + let err = futures::executor::block_on(runtime.get_account(&cx, request)) + .expect_err("no session yet"); + assert!(matches!( + err, + CallError::Domain(HostAccountGetError::V1( + v01::HostAccountGetError::NotConnected + )) + )); + } + + #[test] + fn raw_payload_bytes_wraps_and_decodes() { + let ok = |p| raw_payload_bytes(p).expect("payload ok"); + // Bytes are -wrapped. + assert_eq!( + ok(v01::RawPayload::Bytes { + bytes: b"hi".to_vec() + }), + b"hi".to_vec(), + ); + // A 0x-hex string payload decodes to bytes before wrapping. + assert_eq!( + ok(v01::RawPayload::Payload { + payload: "0xdeadbeef".to_string(), + }), + [ + BYTES_WRAP_PREFIX, + &[0xde, 0xad, 0xbe, 0xef], + BYTES_WRAP_SUFFIX + ] + .concat(), + ); + // A non-hex string payload is signed as UTF-8. + assert_eq!( + ok(v01::RawPayload::Payload { + payload: "hello".to_string(), + }), + b"hello".to_vec(), + ); + // An odd-length 0x string is not `isHex`, so it is signed as UTF-8. + assert_eq!( + ok(v01::RawPayload::Payload { + payload: "0xabc".to_string(), + }), + b"0xabc".to_vec(), + ); + // Already-wrapped input is left untouched (no double wrapping). + assert_eq!( + ok(v01::RawPayload::Bytes { + bytes: b"hi".to_vec(), + }), + b"hi".to_vec(), + ); + // An even-length 0x string that is not valid hex is a hard error, + // never silently signed as UTF-8 (matches polkadot-app abort). + assert!(matches!( + raw_payload_bytes(v01::RawPayload::Payload { + payload: "0xZZ".to_string(), + }), + Err(AuthorityError::Unknown { .. }), + )); + } + + #[test] + fn sign_raw_leaves_already_wrapped_payload_untouched() { + let (services, activation) = signing_runtime(); + futures::executor::block_on(activation.activate_local_session(ENTROPY.to_vec())) + .expect("activation succeeds"); + let runtime = product_runtime(services, activation); + let cx = CallContext::new(); + let request = HostSignRawRequest::V1(v01::HostSignRawRequest { + account: v01::ProductAccountId { + dot_ns_identifier: "myapp.dot".to_string(), + derivation_index: 0, + }, + payload: v01::RawPayload::Bytes { + bytes: b"hi".to_vec(), + }, + }); + let HostSignRawResponse::V1(response) = + futures::executor::block_on(runtime.sign_raw(&cx, request)).expect("sign_raw ok"); + let root = derive_root_keypair_from_entropy(&ENTROPY).unwrap(); + let keypair = derive_product_keypair(&root, "myapp.dot", 0).unwrap(); + let signature = + schnorrkel::Signature::from_bytes(&response.signature).expect("64-byte signature"); + assert!( + keypair + .public + .verify_simple(b"substrate", b"hi", &signature) + .is_ok(), + "signature verifies over the unchanged wrapped message", + ); + assert!( + keypair + .public + .verify_simple( + b"substrate", + b"hi", + &signature + ) + .is_err(), + "payload was not double-wrapped", + ); + } + + #[test] + fn reactivation_invalidates_prior_session_snapshot() { + let (_services, authority) = signing_runtime(); + futures::executor::block_on(authority.activate_local_session(ENTROPY.to_vec())) + .expect("first activation"); + let stale = authority.current_session().expect("snapshot"); + + // Re-activate with different entropy: a fresh public key, hence a + // different validation id. + futures::executor::block_on(authority.activate_local_session([0xCD; 16].to_vec())) + .expect("second activation"); + assert_ne!( + authority.current_session().expect("session").public_key, + stale.public_key, + ); + + let cx = CallContext::new(); + let request = v01::HostSignRawRequest { + account: v01::ProductAccountId { + dot_ns_identifier: "myapp.dot".to_string(), + derivation_index: 0, + }, + payload: v01::RawPayload::Bytes { + bytes: vec![1, 2, 3], + }, + }; + let err = futures::executor::block_on(authority.sign_raw( + &cx, + &stale, + SignRawAuthorityRequest::Product(request), + )) + .expect_err("stale snapshot rejected"); + assert_eq!(err, AuthorityError::Disconnected); + } + + #[test] + fn disconnect_clears_local_session() { + let (_services, authority) = signing_runtime(); + futures::executor::block_on(authority.activate_local_session(ENTROPY.to_vec())) + .expect("activation"); + let session = authority.current_session().expect("connected"); + + futures::executor::block_on(authority.disconnect()); + assert!(authority.current_session().is_none()); + + let cx = CallContext::new(); + let request = v01::HostSignRawRequest { + account: v01::ProductAccountId { + dot_ns_identifier: "myapp.dot".to_string(), + derivation_index: 0, + }, + payload: v01::RawPayload::Bytes { bytes: vec![1] }, + }; + let err = futures::executor::block_on(authority.sign_raw( + &cx, + &session, + SignRawAuthorityRequest::Product(request), + )) + .expect_err("no session after disconnect"); + assert_eq!(err, AuthorityError::Disconnected); + } + + #[test] + fn deferred_operations_return_unavailable() { + let (_services, authority) = signing_runtime(); + futures::executor::block_on(authority.activate_local_session(ENTROPY.to_vec())) + .expect("activation"); + let session = authority.current_session().expect("connected"); + let cx = CallContext::new(); + + let alias = futures::executor::block_on(authority.account_alias( + &cx, + &session, + v01::ProductAccountId { + dot_ns_identifier: "other.dot".to_string(), + derivation_index: 0, + }, + "myapp.dot".to_string(), + )) + .expect_err("alias deferred"); + assert!(matches!(alias, AuthorityError::Unavailable { .. })); + + let alloc = futures::executor::block_on(authority.allocate_resources( + &cx, + &session, + "myapp.dot".to_string(), + v01::HostRequestResourceAllocationRequest { resources: vec![] }, + )) + .expect_err("allocation deferred"); + assert!(matches!(alloc, AuthorityError::Unavailable { .. })); + } +} diff --git a/rust/crates/truapi-server/src/runtime/signing_host/local_activation.rs b/rust/crates/truapi-server/src/runtime/signing_host/local_activation.rs new file mode 100644 index 00000000..9da344b0 --- /dev/null +++ b/rust/crates/truapi-server/src/runtime/signing_host/local_activation.rs @@ -0,0 +1,44 @@ +use super::{SigningHost, product_authority_error}; +use crate::host_logic::product_account::derive_root_keypair_from_entropy; +use crate::host_logic::session::SessionInfo; +use crate::runtime::authority::AuthorityError; +use crate::runtime::connected_session_ui_info; + +use zeroize::Zeroizing; + +/// Establish a wallet-local session from host-held secret material. +/// +/// A signing host owns the user's keys, so it establishes sessions directly +/// rather than through the SSO pairing flow. Only [`SigningHost`] implements +/// this; pairing hosts have no local secret to activate. +#[async_trait::async_trait] +pub(crate) trait LocalActivation: Send + Sync { + /// Activate a local session from raw BIP-39 entropy, deriving the root + /// public key and marking the session connected. + async fn activate_local_session(&self, secret: Vec) -> Result<(), AuthorityError>; +} + +#[async_trait::async_trait] +impl LocalActivation for SigningHost { + async fn activate_local_session(&self, secret: Vec) -> Result<(), AuthorityError> { + let secret = Zeroizing::new(secret); + let root = derive_root_keypair_from_entropy(&secret).map_err(product_authority_error)?; + let public_key = root.public.to_bytes(); + *self + .root_entropy + .lock() + .expect("signing host entropy mutex poisoned") = Some(secret); + let session = SessionInfo { + public_key, + sso: None, + root_entropy_source: None, + identity_account_id: None, + lite_username: None, + full_username: None, + }; + self.session_state.set_session(session.clone()); + self.auth_state + .connected(&connected_session_ui_info(&session)); + Ok(()) + } +} diff --git a/rust/crates/truapi-server/src/runtime/sso_pairing.rs b/rust/crates/truapi-server/src/runtime/sso_pairing.rs new file mode 100644 index 00000000..2fb60d36 --- /dev/null +++ b/rust/crates/truapi-server/src/runtime/sso_pairing.rs @@ -0,0 +1,893 @@ +//! SSO pairing (login): presents the pairing deeplink, watches the bootstrap +//! topic on the statement store (live subscription plus periodic snapshot +//! queries), and decrypts the wallet's V2 handshake response into a session. + +#[cfg(test)] +use std::sync::{Arc, Mutex}; + +#[cfg(not(target_arch = "wasm32"))] +use std::time::Duration; +#[cfg(target_arch = "wasm32")] +use web_time::Duration; + +use super::auth_state::AuthStateMachine; +use super::identity::resolve_session_identity_with_chain; +use super::pairing_host::PairingHost; +use super::statement_store_rpc; +use crate::host_logic::session::{SessionInfo, encode_persisted_session}; +use crate::host_logic::sso::pairing::{ + PairingBootstrap, PairingDeviceIdentity, VersionedHandshakeResponse, + create_pairing_bootstrap_from_identity, decode_app_handshake_data, + decrypt_v2_handshake_response, establish_sso_session_info, generate_pairing_device_identity, + v2, +}; +use crate::host_logic::statement_store::{ + decode_verified_statement_data, parse_new_statements_result, +}; +use crate::subscription::Spawner; + +use futures::channel::{mpsc, oneshot}; +use futures::{FutureExt, StreamExt, pin_mut}; +use parity_scale_codec::Encode; +use serde_json::Value; +use subxt_rpcs::RpcClient; +use subxt_rpcs::client::RpcSubscription; +use tracing::{debug, info, instrument}; +use truapi::CallError; +use truapi::v01; +use truapi::versioned::account::HostRequestLoginError; +#[cfg(test)] +use truapi::versioned::account::HostRequestLoginResponse; +use truapi_platform::{CoreStorage, CoreStorageKey}; + +#[cfg(not(test))] +const PAIRING_QUERY_INTERVAL: Duration = Duration::from_secs(2); +#[cfg(test)] +const PAIRING_QUERY_INTERVAL: Duration = Duration::from_millis(1); +#[cfg(not(test))] +const PAIRING_QUERY_TIMEOUT_TICKS: u8 = 15; +#[cfg(test)] +const PAIRING_QUERY_TIMEOUT_TICKS: u8 = 10; + +/// Terminal outcome of [`SsoPairingFlow::request_session`]. +pub(super) enum SsoPairingOutcome { + /// The login was cancelled (host `cancel_login`, `disconnect`, or a + /// cross-tab session win). + Cancelled, + /// Wallet handshake completed; the session is resolved and persisted. + Success(Box), +} + +/// Resets a `Pairing` state left behind by a dropped login future (e.g. the +/// transport dropping in-flight calls on connection close). A no-op once the +/// flow reached any terminal transition or a newer pairing took over. +struct AbandonedPairingGuard { + auth_state: AuthStateMachine, + epoch: u64, + active: bool, +} + +impl AbandonedPairingGuard { + fn disarm(&mut self) { + self.active = false; + } +} + +impl Drop for AbandonedPairingGuard { + fn drop(&mut self) { + if self.active { + self.auth_state.reset_abandoned_pairing(self.epoch); + } + } +} + +pub(super) struct SsoPairingFlow<'a> { + host: &'a PairingHost, +} + +impl<'a> SsoPairingFlow<'a> { + pub(super) fn new(host: &'a PairingHost) -> Self { + Self { host } + } + + /// `request_session` pairing flow: emits `AuthState::Pairing` for the host + /// to present, then races host cancellation against the wallet handshake + /// arriving on the statement store; on success it resolves identity, + /// persists the new session, and returns it to the pairing host. + pub(super) async fn request_session( + &self, + ) -> Result> { + let pairing_identity = create_fresh_pairing_device_identity(self.host.platform.as_ref()) + .await + .map_err(|reason| self.fail_before_pairing(reason))?; + let bootstrap = + create_pairing_bootstrap_from_identity(&self.host.host_config, pairing_identity) + .map_err(|err| self.fail_before_pairing(err.to_string()))?; + + let Some((cancel_rx, pairing_epoch)) = self + .host + .auth_state + .pairing_started(bootstrap.deeplink.clone()) + else { + return Err(CallError::Domain(HostRequestLoginError::V1( + v01::HostRequestLoginError::Unknown { + reason: "login already in progress".to_string(), + }, + ))); + }; + info!("presenting pairing QR, waiting for wallet handshake"); + let mut reset_guard = AbandonedPairingGuard { + auth_state: self.host.auth_state.clone(), + epoch: pairing_epoch, + active: true, + }; + + match self.run_pairing_flow(&bootstrap, cancel_rx).await { + Ok(outcome @ SsoPairingOutcome::Cancelled) => { + reset_guard.disarm(); + Ok(outcome) + } + Ok(outcome @ SsoPairingOutcome::Success(_)) => { + reset_guard.disarm(); + Ok(outcome) + } + Err(reason) => { + self.host.auth_state.login_failed(reason.clone()); + Err(CallError::HostFailure { reason }) + } + } + } + + /// Emit `LoginFailed` for an error raised before the pairing was entered + /// and map it onto the `request_login` error shape. + fn fail_before_pairing(&self, reason: String) -> CallError { + self.host + .auth_state + .login_failed_before_pairing(reason.clone()); + CallError::Domain(HostRequestLoginError::V1( + v01::HostRequestLoginError::Unknown { reason }, + )) + } + + /// Everything between the `Pairing` emission and a terminal outcome. + /// Every error returned here maps to `AuthState::LoginFailed` at the + /// single exit in [`Self::request_login`]. + async fn run_pairing_flow( + &self, + bootstrap: &PairingBootstrap, + cancel_rx: oneshot::Receiver<()>, + ) -> Result { + let mut cancel = cancel_rx.fuse(); + let statement_store = self.host.statement_store.clone(); + let statement_store_connect = statement_store.client("pairing statement-store").fuse(); + pin_mut!(statement_store_connect); + + let rpc_client = futures::select! { + _ = cancel => return Ok(SsoPairingOutcome::Cancelled), + connect_result = statement_store_connect => connect_result?, + }; + let subscribe_client = rpc_client.clone(); + let live_topics = [bootstrap.topic]; + let live_subscription = + statement_store_rpc::subscribe_match_all(&subscribe_client, &live_topics).fuse(); + pin_mut!(live_subscription); + let live_subscription = futures::select! { + _ = cancel => return Ok(SsoPairingOutcome::Cancelled), + subscribe_result = live_subscription => subscribe_result + .map_err(|err| format!("pairing statement-store subscribe failed: {err}"))?, + }; + debug!("subscribed to pairing topic, polling statement store"); + let pairing_response = wait_for_v2_pairing_success( + rpc_client, + live_subscription, + bootstrap.topic, + bootstrap.encryption_secret_key, + self.host.spawner.clone(), + ) + .fuse(); + pin_mut!(pairing_response); + + let response = futures::select! { + _ = cancel => return Ok(SsoPairingOutcome::Cancelled), + response_result = pairing_response => response_result?, + }; + let sso = establish_sso_session_info( + bootstrap, + response.peer_statement_account_id, + response.success.sso_enc_pub_key, + )?; + let session = SessionInfo { + public_key: response.success.root_account_id, + sso: Some(sso), + root_entropy_source: Some(response.success.root_entropy_source), + identity_account_id: Some(response.success.identity_account_id), + lite_username: None, + full_username: None, + }; + let resolve_session = resolve_session_identity_with_chain( + &self.host.chain, + self.host.host_config.people_chain_genesis_hash, + session, + ) + .fuse(); + pin_mut!(resolve_session); + let session = futures::select! { + _ = cancel => return Ok(SsoPairingOutcome::Cancelled), + session = resolve_session => session, + }; + let persist_session = self + .host + .platform + .write_core_storage( + CoreStorageKey::AuthSession, + encode_persisted_session(&session), + ) + .fuse(); + pin_mut!(persist_session); + futures::select! { + _ = cancel => return Ok(SsoPairingOutcome::Cancelled), + persist_result = persist_session => persist_result + .map_err(|err| format!("session persist failed: {err:?}"))?, + }; + Ok(SsoPairingOutcome::Success(Box::new(session))) + } +} + +#[instrument(skip_all, fields(runtime.method = "sso.pairing_device.create_fresh"))] +async fn create_fresh_pairing_device_identity( + storage: &(impl CoreStorage + ?Sized), +) -> Result { + let identity = generate_pairing_device_identity() + .map_err(|err| format!("pairing identity failed: {err}"))?; + storage + .write_core_storage(CoreStorageKey::PairingDeviceIdentity, identity.encode()) + .await + .map_err(|err| format!("pairing device identity write failed: {err:?}"))?; + Ok(identity) +} + +struct PairingSuccess { + peer_statement_account_id: [u8; 32], + success: v2::Success, +} + +impl PairingSuccess { + #[instrument(skip_all, fields(runtime.method = "sso.pairing.decode_statement"))] + fn from_v2_statement( + statement: &[u8], + core_encryption_secret_key: [u8; 32], + ) -> Result, String> { + let verified = + decode_verified_statement_data(statement, None).map_err(|err| err.to_string())?; + let VersionedHandshakeResponse::V2 { + encrypted_message, + public_key, + } = decode_app_handshake_data(&verified.data)?; + match decrypt_v2_handshake_response( + core_encryption_secret_key, + public_key, + &encrypted_message, + )? { + v2::EncryptedResponse::Pending(_) => Ok(None), + v2::EncryptedResponse::Failed(reason) => Err(reason), + v2::EncryptedResponse::Success(success) => Ok(Some(Self { + peer_statement_account_id: verified.signer, + success: *success, + })), + } + } +} + +#[instrument(skip_all, fields(runtime.method = "sso.pairing.wait_success"))] +async fn wait_for_v2_pairing_success( + rpc_client: RpcClient, + mut live_subscription: RpcSubscription, + topic: [u8; 32], + core_encryption_secret_key: [u8; 32], + spawner: Spawner, +) -> Result { + let (query_tx, mut query_rx) = mpsc::unbounded(); + let mut query_active = false; + let poll = futures_timer::Delay::new(PAIRING_QUERY_INTERVAL).fuse(); + pin_mut!(poll); + loop { + futures::select! { + item = live_subscription.next().fuse() => { + let Some(item) = item else { + return Err("pairing statement-store live subscription ended".to_string()); + }; + let value = item.map_err(|err| format!("pairing statement-store live error: {err}"))?; + if let Some(success) = handle_v2_pairing_result(&value, core_encryption_secret_key)? { + return Ok(success); + } + } + query = query_rx.next().fuse() => { + query_active = false; + if let Some(query) = query + && let Some(success) = query? { + return Ok(success); + } + } + _ = poll => { + if !query_active { + query_active = true; + let rpc_client = rpc_client.clone(); + let query_tx = query_tx.clone(); + let fut = async move { + let result = run_pairing_snapshot_query( + rpc_client, + topic, + core_encryption_secret_key, + ).await; + let _ = query_tx.unbounded_send(result); + }; + // `RpcClient` is transport-only here; spawning lets live + // notifications continue to be consumed while a snapshot + // query is waiting for backlog completion or timeout. + (spawner)(fut.boxed()); + } + poll.set(futures_timer::Delay::new(PAIRING_QUERY_INTERVAL).fuse()); + } + } + } +} + +#[instrument(skip_all, fields(runtime.method = "sso.pairing.snapshot_query"))] +async fn run_pairing_snapshot_query( + rpc_client: RpcClient, + topic: [u8; 32], + core_encryption_secret_key: [u8; 32], +) -> Result, String> { + let topics = [topic]; + let mut subscription = statement_store_rpc::subscribe_match_all(&rpc_client, &topics) + .await + .map_err(|err| format!("pairing statement-store query failed: {err}"))?; + for _ in 0..PAIRING_QUERY_TIMEOUT_TICKS { + let timeout = futures_timer::Delay::new(PAIRING_QUERY_INTERVAL).fuse(); + pin_mut!(timeout); + futures::select! { + item = subscription.next().fuse() => { + let Some(item) = item else { + return Ok(None); + }; + let value = item.map_err(|err| format!("pairing statement-store query item failed: {err}"))?; + if let Some(success) = handle_v2_pairing_result(&value, core_encryption_secret_key)? { + return Ok(Some(success)); + } + let page = parse_new_statements_result("query".to_string(), &value) + .map_err(|err| err.to_string())?; + if page.remaining == Some(0) { + return Ok(None); + } + } + _ = timeout => {} + } + } + Ok(None) +} + +#[instrument(skip_all, fields(runtime.method = "sso.pairing.handle_result"))] +fn handle_v2_pairing_result( + value: &Value, + core_encryption_secret_key: [u8; 32], +) -> Result, String> { + let page = + parse_new_statements_result("pairing".to_string(), value).map_err(|err| err.to_string())?; + for statement in page.statements { + if let Some(success) = + PairingSuccess::from_v2_statement(&statement, core_encryption_secret_key)? + { + return Ok(Some(success)); + } + } + + Ok(None) +} + +#[cfg(test)] +mod tests { + use super::super::connected_session_ui_info; + use super::super::{PairingHostRole, ProductRuntimeHost}; + use super::*; + use crate::host_rpc_client::HostRpcClient; + use crate::test_support::{ + StubPlatform, core_storage_test_key, pairing_device_from_deeplink, peer_statement_keypair, + runtime_config, session_info, signed_test_statement, stub_platform, subscribe_ack_frame, + test_spawner, wallet_handshake_statement, + }; + use p256::elliptic_curve::sec1::ToEncodedPoint; + use truapi::CallContext; + use truapi::api::Account; + use truapi::versioned::account::{ + HostAccountConnectionStatusSubscribeItem, HostRequestLoginRequest, + }; + use truapi_platform::{AuthState, ChainProvider, CoreStorageKey}; + + /// Cancel the login as soon as the host observes the `Pairing` state, + /// mimicking a user dismissing the pairing UI immediately. + fn cancel_on_pairing(platform: &StubPlatform, pairing_host: Arc) { + *platform + .on_auth_state + .lock() + .expect("auth state hook mutex poisoned") = Some(Arc::new(move |state| { + if matches!(state, AuthState::Pairing { .. }) { + pairing_host.cancel_login(); + } + })); + } + + #[test] + fn request_login_presents_pairing_and_rejects_when_cancelled() { + let platform = stub_platform(); + let (host, pairing_host) = + ProductRuntimeHost::new_compat_with_pairing(platform.clone(), test_spawner()); + let host = Arc::new(host); + cancel_on_pairing(&platform, pairing_host); + let cx = CallContext::new(); + let request = HostRequestLoginRequest::V1(v01::HostRequestLoginRequest { reason: None }); + let response = futures::executor::block_on(host.request_login(&cx, request)).unwrap(); + + assert_eq!( + response, + HostRequestLoginResponse::V1(v01::HostRequestLoginResponse::Rejected) + ); + let auth_states = platform + .auth_states + .lock() + .expect("auth state list mutex poisoned"); + assert_eq!(auth_states.len(), 2, "states: {auth_states:?}"); + match &auth_states[0] { + AuthState::Pairing { deeplink } => { + assert!(deeplink.starts_with("polkadotapp://pair?handshake=")); + } + other => panic!("expected pairing state first, got {other:?}"), + } + assert_eq!(auth_states[1], AuthState::Disconnected); + + let sent_rpc = platform.sent_rpc.lock().expect("rpc list mutex poisoned"); + if let Some(sent) = sent_rpc.first() { + let request: serde_json::Value = serde_json::from_str(sent).unwrap(); + assert_eq!(request["method"], "statement_subscribeStatement"); + assert_eq!( + request["params"][0]["matchAll"][0].as_str().unwrap().len(), + 66 + ); + } + } + + #[test] + fn request_login_rotates_pairing_device_identity_between_attempts() { + let platform = stub_platform(); + let (host, pairing_host) = + ProductRuntimeHost::new_compat_with_pairing(platform.clone(), test_spawner()); + let host = Arc::new(host); + cancel_on_pairing(&platform, pairing_host); + let cx = CallContext::new(); + let request = HostRequestLoginRequest::V1(v01::HostRequestLoginRequest { reason: None }); + + let first = futures::executor::block_on(host.request_login(&cx, request.clone())).unwrap(); + let second = futures::executor::block_on(host.request_login(&cx, request)).unwrap(); + + assert_eq!( + first, + HostRequestLoginResponse::V1(v01::HostRequestLoginResponse::Rejected) + ); + assert_eq!( + second, + HostRequestLoginResponse::V1(v01::HostRequestLoginResponse::Rejected) + ); + let deeplinks: Vec = platform + .auth_states + .lock() + .expect("auth state list mutex poisoned") + .iter() + .filter_map(|state| match state { + AuthState::Pairing { deeplink } => Some(deeplink.clone()), + _ => None, + }) + .collect(); + assert_eq!(deeplinks.len(), 2); + assert_ne!( + pairing_device_from_deeplink(&deeplinks[0]), + pairing_device_from_deeplink(&deeplinks[1]) + ); + assert!( + platform + .local_storage + .lock() + .expect("local storage mutex poisoned") + .contains_key(&core_storage_test_key( + CoreStorageKey::PairingDeviceIdentity + )) + ); + } + + #[test] + fn request_login_waits_for_pairing_statement() { + let wallet_ephemeral_secret = p256::SecretKey::from_slice(&[2; 32]).unwrap(); + let wallet_ephemeral_public = wallet_ephemeral_secret.public_key().to_encoded_point(false); + let mut wallet_ephemeral_public_bytes = [0u8; 65]; + wallet_ephemeral_public_bytes.copy_from_slice(wallet_ephemeral_public.as_bytes()); + let handshake = VersionedHandshakeResponse::V2 { + encrypted_message: vec![0xde, 0xad], + public_key: wallet_ephemeral_public_bytes, + }; + let statement = signed_test_statement(handshake.encode()); + let notification = format!( + r#"{{"jsonrpc":"2.0","method":"statement_subscribeStatement","params":{{"subscription":"remote-sub","result":{{"event":"newStatements","data":{{"statements":["0x{}"],"remaining":0}}}}}}}}"#, + hex::encode(statement) + ); + let platform = Arc::new(StubPlatform { + rpc_responses: vec![ + r#"{"jsonrpc":"2.0","id":"truapi:1","result":"remote-sub"}"#.to_string(), + notification, + ], + ..Default::default() + }); + let host = ProductRuntimeHost::new_compat(platform.clone(), test_spawner()); + let cx = CallContext::new(); + let request = HostRequestLoginRequest::V1(v01::HostRequestLoginRequest { reason: None }); + let err = futures::executor::block_on(host.request_login(&cx, request)).unwrap_err(); + + match err { + CallError::HostFailure { reason } => { + assert_eq!(reason, "encrypted SSO handshake answer is too short"); + } + other => panic!("expected handshake decrypt failure, got {other:?}"), + } + let sent_rpc = platform.sent_rpc.lock().expect("rpc list mutex poisoned"); + let requests = sent_rpc + .iter() + .map(|request| serde_json::from_str::(request).unwrap()) + .collect::>(); + let methods = requests + .iter() + .map(|request| request["method"].as_str().unwrap()) + .collect::>(); + assert_eq!( + methods.first().copied(), + Some("statement_subscribeStatement") + ); + assert!( + methods.contains(&"statement_unsubscribeStatement"), + "pairing subscription should be cleaned up" + ); + let unsubscribe = requests + .iter() + .find(|request| request["method"].as_str() == Some("statement_unsubscribeStatement")) + .expect("pairing subscription should be cleaned up"); + assert_eq!(unsubscribe["params"][0], "remote-sub"); + } + + #[test] + fn request_login_accepts_valid_pairing_statement_and_persists_session() { + let session_writes = Arc::new(Mutex::new(Vec::new())); + let platform = Arc::new(StubPlatform { + pairing_success_response: true, + session_writes: session_writes.clone(), + ..Default::default() + }); + let host = ProductRuntimeHost::new( + platform.clone(), + runtime_config("myapp.dot"), + test_spawner(), + ); + let mut statuses = host.test_session_state().subscribe(); + assert_eq!( + futures::executor::block_on(statuses.next()).unwrap(), + HostAccountConnectionStatusSubscribeItem::V1( + v01::HostAccountConnectionStatusSubscribeItem::Disconnected + ) + ); + + let cx = CallContext::new(); + let request = HostRequestLoginRequest::V1(v01::HostRequestLoginRequest { reason: None }); + let response = futures::executor::block_on(host.request_login(&cx, request)).unwrap(); + + assert_eq!( + response, + HostRequestLoginResponse::V1(v01::HostRequestLoginResponse::Success) + ); + assert_eq!( + futures::executor::block_on(statuses.next()).unwrap(), + HostAccountConnectionStatusSubscribeItem::V1( + v01::HostAccountConnectionStatusSubscribeItem::Connected + ) + ); + + let session = host + .test_session_state() + .current() + .expect("paired session should be active"); + assert_eq!(session.public_key, session_info().public_key); + assert_eq!(session.root_entropy_source, Some([0x66; 32])); + assert_eq!( + session.sso.as_ref().unwrap().identity_account_id, + peer_statement_keypair().1 + ); + + let writes = session_writes + .lock() + .expect("session write list mutex poisoned"); + assert_eq!(writes.len(), 1); + assert_eq!( + crate::host_logic::session::decode_persisted_session(&writes[0]).unwrap(), + session + ); + + let auth_states = platform + .auth_states + .lock() + .expect("auth state list mutex poisoned"); + assert_eq!(auth_states.len(), 2, "states: {auth_states:?}"); + assert!(matches!(&auth_states[0], AuthState::Pairing { .. })); + assert_eq!( + auth_states[1], + AuthState::Connected(connected_session_ui_info(&session)) + ); + drop(auth_states); + + let methods = platform + .sent_rpc + .lock() + .expect("rpc list mutex poisoned") + .iter() + .map(|request| serde_json::from_str::(request).unwrap()) + .map(|request| request["method"].as_str().unwrap().to_string()) + .collect::>(); + assert_eq!( + methods.first().map(String::as_str), + Some("statement_subscribeStatement") + ); + assert!( + methods + .iter() + .any(|method| method == "statement_unsubscribeStatement"), + "pairing subscription should be cleaned up" + ); + } + + #[test] + fn request_login_connected_callback_can_clear_session_without_reinstalling_it() { + let platform = Arc::new(StubPlatform { + pairing_success_response: true, + ..Default::default() + }); + let host = Arc::new(ProductRuntimeHost::new( + platform.clone(), + runtime_config("myapp.dot"), + test_spawner(), + )); + let disconnect_host = host.clone(); + *platform + .on_auth_state + .lock() + .expect("auth state hook mutex poisoned") = Some(Arc::new(move |state| { + if matches!(state, AuthState::Connected(_)) { + disconnect_host.test_session_state().clear_session(); + } + })); + + let cx = CallContext::new(); + let request = HostRequestLoginRequest::V1(v01::HostRequestLoginRequest { reason: None }); + let response = futures::executor::block_on(host.request_login(&cx, request)).unwrap(); + + assert_eq!( + response, + HostRequestLoginResponse::V1(v01::HostRequestLoginResponse::Success) + ); + assert!(host.test_session_state().current().is_none()); + + let auth_states = platform + .auth_states + .lock() + .expect("auth state list mutex poisoned"); + assert!(matches!(&auth_states[0], AuthState::Pairing { .. })); + assert!(matches!(&auth_states[1], AuthState::Connected(_))); + } + + /// Pairing success must also be decoded from a snapshot query page, not only + /// from the live pairing subscription. + #[test] + fn request_login_accepts_pairing_statement_from_snapshot_query_page() { + let (host_config, _) = runtime_config("myapp.dot"); + let pairing_identity = generate_pairing_device_identity().unwrap(); + let bootstrap = + create_pairing_bootstrap_from_identity(&host_config, pairing_identity).unwrap(); + let statement = wallet_handshake_statement(&bootstrap.deeplink); + let platform = Arc::new(StubPlatform { + rpc_responses: vec![ + subscribe_ack_frame("truapi:1", "query-sub"), + crate::test_support::new_statements_frame("query-sub", vec![statement]), + ], + ..Default::default() + }); + let connection = + futures::executor::block_on(platform.connect(host_config.people_chain_genesis_hash)) + .unwrap(); + let rpc_client = RpcClient::new(HostRpcClient::new(Arc::from(connection), test_spawner())); + let success = futures::executor::block_on(run_pairing_snapshot_query( + rpc_client, + bootstrap.topic, + bootstrap.encryption_secret_key, + )) + .unwrap() + .expect("snapshot query should return pairing success"); + + assert_eq!( + success.peer_statement_account_id, + peer_statement_keypair().1 + ); + assert_eq!(success.success.root_account_id, session_info().public_key); + + let methods = platform + .sent_rpc + .lock() + .expect("rpc list mutex poisoned") + .iter() + .map(|request| serde_json::from_str::(request).unwrap()) + .map(|request| request["method"].as_str().unwrap().to_string()) + .collect::>(); + assert_eq!( + methods.first().map(String::as_str), + Some("statement_subscribeStatement") + ); + } + + #[test] + fn request_login_emits_login_failed_for_pre_pairing_errors() { + let platform = Arc::new(StubPlatform { + local_storage_error: Some("identity storage unavailable"), + ..Default::default() + }); + let host = ProductRuntimeHost::new_compat(platform.clone(), test_spawner()); + let cx = CallContext::new(); + let request = HostRequestLoginRequest::V1(v01::HostRequestLoginRequest { reason: None }); + let err = futures::executor::block_on(host.request_login(&cx, request)).unwrap_err(); + + assert!(matches!(err, CallError::Domain(_))); + let auth_states = platform + .auth_states + .lock() + .expect("auth state list mutex poisoned"); + assert_eq!(auth_states.len(), 1, "states: {auth_states:?}"); + assert!(matches!(&auth_states[0], AuthState::LoginFailed { reason } + if reason.contains("identity storage unavailable"))); + } + + #[test] + fn dropped_request_login_clears_single_flight_for_next_attempt() { + use std::future::Future; + use std::task::{Context, Poll}; + + let platform = Arc::new(StubPlatform { + chain_connect_pending: true, + ..Default::default() + }); + let (host, pairing_host) = + ProductRuntimeHost::new_compat_with_pairing(platform.clone(), test_spawner()); + let host = Arc::new(host); + let request = HostRequestLoginRequest::V1(v01::HostRequestLoginRequest { reason: None }); + let cx = CallContext::new(); + let mut first_login = Box::pin(host.request_login(&cx, request.clone())); + let waker = futures::task::noop_waker(); + let mut task_cx = Context::from_waker(&waker); + + match first_login.as_mut().poll(&mut task_cx) { + Poll::Pending => {} + Poll::Ready(result) => panic!("first login should be pending, got {result:?}"), + } + assert!( + platform + .auth_states + .lock() + .expect("auth state list mutex poisoned") + .iter() + .any(|state| matches!(state, AuthState::Pairing { .. })), + "first login did not enter pairing state" + ); + + drop(first_login); + + assert!( + platform + .pending_connect_dropped + .load(std::sync::atomic::Ordering::SeqCst), + "dropping the login future should drop the pending statement-store connect" + ); + + cancel_on_pairing(&platform, pairing_host); + let second_cx = CallContext::new(); + let mut second_login = Box::pin(host.request_login(&second_cx, request)); + let second = match second_login.as_mut().poll(&mut task_cx) { + Poll::Ready(result) => result.expect("second login should complete after cancellation"), + Poll::Pending => panic!("second login stayed pending behind stale single-flight state"), + }; + assert_eq!( + second, + HostRequestLoginResponse::V1(v01::HostRequestLoginResponse::Rejected) + ); + } + + #[test] + fn request_login_does_not_restore_persisted_session_before_pairing() { + let stored = session_info(); + let platform = Arc::new(StubPlatform { + session_blob: Some(crate::host_logic::session::encode_persisted_session( + &stored, + )), + ..Default::default() + }); + let (host, pairing_host) = + ProductRuntimeHost::new_compat_with_pairing(platform.clone(), test_spawner()); + let host = Arc::new(host); + cancel_on_pairing(&platform, pairing_host); + let cx = CallContext::new(); + let request = HostRequestLoginRequest::V1(v01::HostRequestLoginRequest { reason: None }); + let response = futures::executor::block_on(host.request_login(&cx, request)).unwrap(); + + assert_eq!( + response, + HostRequestLoginResponse::V1(v01::HostRequestLoginResponse::Rejected) + ); + assert!(host.test_session_state().current().is_none()); + } + + #[test] + fn request_login_ignores_corrupt_persisted_session_before_pairing() { + let session_clears = Arc::new(Mutex::new(0)); + let platform = Arc::new(StubPlatform { + session_blob: Some(vec![0xff]), + session_clears: session_clears.clone(), + ..Default::default() + }); + let (host, pairing_host) = + ProductRuntimeHost::new_compat_with_pairing(platform.clone(), test_spawner()); + let host = Arc::new(host); + cancel_on_pairing(&platform, pairing_host); + let cx = CallContext::new(); + let request = HostRequestLoginRequest::V1(v01::HostRequestLoginRequest { reason: None }); + let response = futures::executor::block_on(host.request_login(&cx, request)).unwrap(); + + assert_eq!( + response, + HostRequestLoginResponse::V1(v01::HostRequestLoginResponse::Rejected) + ); + assert!(host.test_session_state().current().is_none()); + assert_eq!(*session_clears.lock().unwrap(), 0); + } + + #[test] + fn request_login_ignores_session_store_failure_before_pairing() { + let platform = Arc::new(StubPlatform { + session_error: Some("storage failed"), + ..Default::default() + }); + let (host, pairing_host) = + ProductRuntimeHost::new_compat_with_pairing(platform.clone(), test_spawner()); + let host = Arc::new(host); + cancel_on_pairing(&platform, pairing_host); + let cx = CallContext::new(); + let request = HostRequestLoginRequest::V1(v01::HostRequestLoginRequest { reason: None }); + let response = futures::executor::block_on(host.request_login(&cx, request)).unwrap(); + + assert_eq!( + response, + HostRequestLoginResponse::V1(v01::HostRequestLoginResponse::Rejected) + ); + assert!(host.test_session_state().current().is_none()); + } + + #[test] + fn request_login_returns_already_connected_when_session_exists() { + let host = ProductRuntimeHost::new_compat(stub_platform(), test_spawner()); + host.test_session_state().set_session(session_info()); + let cx = CallContext::new(); + let request = HostRequestLoginRequest::V1(v01::HostRequestLoginRequest { reason: None }); + let response = futures::executor::block_on(host.request_login(&cx, request)).unwrap(); + assert_eq!( + response, + HostRequestLoginResponse::V1(v01::HostRequestLoginResponse::AlreadyConnected) + ); + } +} diff --git a/rust/crates/truapi-server/src/runtime/sso_remote.rs b/rust/crates/truapi-server/src/runtime/sso_remote.rs new file mode 100644 index 00000000..9b154e0b --- /dev/null +++ b/rust/crates/truapi-server/src/runtime/sso_remote.rs @@ -0,0 +1,551 @@ +//! SSO remote messaging over the people-chain statement store: submits an +//! encrypted request statement to the paired signing host and waits for the +//! matching response, honoring timeouts and local/peer disconnect signals. + +use core::mem; +use std::fmt::{self, Display}; +use std::sync::Mutex; + +#[cfg(not(target_arch = "wasm32"))] +use std::time::Duration; +#[cfg(target_arch = "wasm32")] +use web_time::Duration; + +use super::statement_store_rpc; +use crate::host_logic::session::SsoSessionInfo; +use crate::host_logic::sso::messages::{ + SsoRemoteResponse, SsoSessionStatement, decode_sso_session_statement, +}; +use crate::host_logic::statement_store::{current_unix_secs, parse_new_statements_result}; + +use futures::channel::oneshot; +use futures::future::BoxFuture; +use futures::stream::BoxStream; +use futures::{FutureExt, StreamExt, pin_mut}; +use serde_json::Value; +use subxt_rpcs::RpcClient; +use subxt_rpcs::client::RpcSubscription; +use tracing::instrument; +use truapi::{CallContext, CancellationReason, CancellationToken}; + +/// Host-spec B.3.3 recommends seven-day statement expiry for session traffic: +/// +const DEFAULT_SSO_STATEMENT_EXPIRY_SECS: u64 = 7 * 24 * 60 * 60; +/// Disconnect reason reported when the local session logs out mid-request. +pub(super) const SSO_LOCAL_DISCONNECT_REASON: &str = "SSO session disconnected"; +/// Disconnect reason reported when the paired signing host announces a disconnect. +pub(super) const SSO_PEER_DISCONNECT_REASON: &str = "SSO peer disconnected"; +/// Reason reported when the product caller cancels a pending SSO request. +const SSO_CALL_CANCELLED_REASON: &str = "SSO response wait cancelled by caller"; + +/// Registry of oneshot waiters resolved when the SSO session disconnects. +#[derive(Default)] +pub(super) struct SessionDisconnects { + inner: Mutex, +} + +#[derive(Default)] +struct SessionDisconnectsInner { + next_id: u64, + waiters: Vec<(u64, SsoSessionKey, oneshot::Sender)>, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) struct SsoSessionKey { + own: [u8; 32], + peer: [u8; 32], +} + +impl SsoSessionKey { + pub(super) fn from_session(session: &SsoSessionInfo) -> Self { + Self { + own: session.session_id_own, + peer: session.session_id_peer, + } + } +} + +pub(super) struct SessionDisconnectGuard { + disconnects: std::sync::Arc, + id: u64, +} + +impl Drop for SessionDisconnectGuard { + fn drop(&mut self) { + self.disconnects.unsubscribe(self.id); + } +} + +impl SessionDisconnects { + /// Register a waiter; returns its id and the disconnect-reason receiver. + pub(super) fn subscribe( + self: &std::sync::Arc, + session: &SsoSessionInfo, + ) -> (SessionDisconnectGuard, oneshot::Receiver) { + let (tx, rx) = oneshot::channel(); + let mut inner = self + .inner + .lock() + .expect("session disconnect mutex poisoned"); + inner.next_id = inner.next_id.wrapping_add(1); + let id = inner.next_id; + inner + .waiters + .push((id, SsoSessionKey::from_session(session), tx)); + ( + SessionDisconnectGuard { + disconnects: self.clone(), + id, + }, + rx, + ) + } + + fn unsubscribe(&self, id: u64) { + self.inner + .lock() + .expect("session disconnect mutex poisoned") + .waiters + .retain(|(waiter_id, _, _)| *waiter_id != id); + } + + /// Resolve pending waiters for one SSO session with `reason`. + pub(super) fn notify(&self, session: &SsoSessionInfo, reason: &'static str) { + self.notify_key(SsoSessionKey::from_session(session), reason); + } + + pub(super) fn notify_key(&self, key: SsoSessionKey, reason: &'static str) { + let waiters = { + let mut inner = self + .inner + .lock() + .expect("session disconnect mutex poisoned"); + let mut matching = Vec::new(); + let mut pending = Vec::with_capacity(inner.waiters.len()); + for waiter in mem::take(&mut inner.waiters) { + if waiter.1 == key { + matching.push(waiter); + } else { + pending.push(waiter); + } + } + inner.waiters = pending; + matching + }; + for (_, _, waiter) in waiters { + let _ = waiter.send(reason.to_string()); + } + } +} + +pub(super) type StatementPageStream = BoxStream<'static, Result>; +pub(super) type StatementSubmitFuture = BoxFuture<'static, Result<(), SsoRemoteResponseError>>; + +pub(super) struct RemoteResponseWait<'a> { + pub(super) own_statements: StatementPageStream, + pub(super) peer_statements: StatementPageStream, + pub(super) submit: StatementSubmitFuture, + pub(super) session: &'a SsoSessionInfo, + pub(super) statement_request_id: &'a str, + pub(super) remote_message_id: &'a str, + pub(super) cancel: &'a CancellationToken, + pub(super) disconnect: Option>, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) struct CancelError { + reason: CancellationReason, + remote_message_id: String, +} + +impl CancelError { + fn new(reason: CancellationReason, remote_message_id: &str) -> Self { + Self { + reason, + remote_message_id: remote_message_id.to_string(), + } + } + + pub(super) fn reason(&self) -> CancellationReason { + self.reason.clone() + } + + pub(super) fn remote_message_id(&self) -> &str { + &self.remote_message_id + } +} + +impl Display for CancelError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match &self.reason { + CancellationReason::Cancelled => { + write!( + f, + "{SSO_CALL_CANCELLED_REASON} for {}", + self.remote_message_id + ) + } + CancellationReason::TimedOut { timeout } => write!( + f, + "SSO response timed out after {} for {}", + format_timeout_duration(*timeout), + self.remote_message_id + ), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) enum SsoRemoteResponseError { + Cancelled(CancelError), + LocalDisconnected, + PeerDisconnected, + Failure(String), +} + +impl Display for SsoRemoteResponseError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Cancelled(err) => err.fmt(f), + Self::LocalDisconnected => f.write_str(SSO_LOCAL_DISCONNECT_REASON), + Self::PeerDisconnected => f.write_str(SSO_PEER_DISCONNECT_REASON), + Self::Failure(reason) => f.write_str(reason), + } + } +} + +impl From for SsoRemoteResponseError { + fn from(reason: String) -> Self { + Self::Failure(reason) + } +} + +fn disconnect_error(reason: String) -> SsoRemoteResponseError { + match reason.as_str() { + SSO_LOCAL_DISCONNECT_REASON => SsoRemoteResponseError::LocalDisconnected, + SSO_PEER_DISCONNECT_REASON => SsoRemoteResponseError::PeerDisconnected, + _ => SsoRemoteResponseError::Failure(reason), + } +} + +#[instrument(skip_all, fields(runtime.method = "sso.remote_response.wait"))] +pub(super) async fn wait_for_sso_remote_response( + wait: RemoteResponseWait<'_>, +) -> Result { + let RemoteResponseWait { + own_statements, + peer_statements, + submit, + session, + statement_request_id, + remote_message_id, + cancel, + disconnect, + } = wait; + let response = wait_for_sso_remote_response_inner( + own_statements, + peer_statements, + submit, + session, + statement_request_id, + remote_message_id, + ) + .fuse(); + let disconnect = async move { + match disconnect { + Some(rx) => match rx.await { + Ok(reason) => disconnect_error(reason), + Err(_) => SsoRemoteResponseError::LocalDisconnected, + }, + None => futures::future::pending::().await, + } + } + .fuse(); + let cancel_message_id = remote_message_id.to_string(); + let cancelled = async move { + let reason = cancel.cancelled().await; + SsoRemoteResponseError::Cancelled(CancelError::new(reason, &cancel_message_id)) + } + .fuse(); + pin_mut!(response, disconnect, cancelled); + futures::select! { + result = response => result, + reason = disconnect => Err(reason), + reason = cancelled => Err(reason), + } +} + +#[instrument(skip_all, fields(runtime.method = "sso.remote_response.wait_inner"))] +async fn wait_for_sso_remote_response_inner( + own_statements: StatementPageStream, + peer_statements: StatementPageStream, + submit: StatementSubmitFuture, + session: &SsoSessionInfo, + statement_request_id: &str, + remote_message_id: &str, +) -> Result { + let mut own_statements = own_statements.fuse(); + let mut peer_statements = peer_statements.fuse(); + let mut submit = submit.fuse(); + let mut own_done = false; + let mut peer_done = false; + let mut request_accepted = false; + let mut pending_remote_response = None; + + loop { + if own_done && peer_done { + return Err(SsoRemoteResponseError::Failure(format!( + "SSO response stream ended before response for {}", + remote_message_id + ))); + } + futures::select! { + item = own_statements.next() => { + match item { + Some(Ok(value)) => { + if let Some(response) = handle_sso_remote_statement_page( + session, + &value, + statement_request_id, + remote_message_id, + &mut request_accepted, + &mut pending_remote_response, + )? { + return Ok(response); + } + } + Some(Err(reason)) => return Err(SsoRemoteResponseError::Failure(reason)), + None => own_done = true, + } + } + item = peer_statements.next() => { + match item { + Some(Ok(value)) => { + if let Some(response) = handle_sso_remote_statement_page( + session, + &value, + statement_request_id, + remote_message_id, + &mut request_accepted, + &mut pending_remote_response, + )? { + return Ok(response); + } + } + Some(Err(reason)) => return Err(SsoRemoteResponseError::Failure(reason)), + None => peer_done = true, + } + } + submit_result = submit => { + submit_result?; + } + } + } +} + +fn handle_sso_remote_statement_page( + session: &SsoSessionInfo, + value: &Value, + statement_request_id: &str, + remote_message_id: &str, + request_accepted: &mut bool, + pending_remote_response: &mut Option, +) -> Result, SsoRemoteResponseError> { + let page = parse_new_statements_result("sso-remote".to_string(), value) + .map_err(|err| SsoRemoteResponseError::Failure(err.to_string()))?; + for statement in page.statements { + match decode_sso_session_statement( + session, + &statement, + statement_request_id, + remote_message_id, + ) + .map_err(SsoRemoteResponseError::Failure)? + { + Some(SsoSessionStatement::RequestAccepted) => { + *request_accepted = true; + if let Some(response) = pending_remote_response.take() { + return Ok(Some(response)); + } + } + Some(SsoSessionStatement::RemoteResponse(response)) => { + if *request_accepted { + return Ok(Some(response)); + } + *pending_remote_response = Some(response); + } + Some(SsoSessionStatement::Disconnected) => { + return Err(SsoRemoteResponseError::PeerDisconnected); + } + None => {} + } + } + Ok(None) +} + +pub(super) async fn subscribe_statement_topic( + rpc_client: &RpcClient, + topic: [u8; 32], +) -> Result, subxt_rpcs::Error> { + statement_store_rpc::subscribe_match_all(rpc_client, &[topic]).await +} + +pub(super) fn statement_subscription_stream( + subscription: RpcSubscription, + label: &'static str, +) -> StatementPageStream { + subscription + .map(move |item| item.map_err(|err| format!("SSO {label} subscription failed: {err}"))) + .boxed() +} + +fn format_timeout_duration(duration: Duration) -> String { + if duration.subsec_millis() == 0 { + format!("{}s", duration.as_secs()) + } else { + format!("{}ms", duration.as_millis()) + } +} + +/// Stable message id for an SSO request: the wire request id when present, +/// otherwise a fixed per-action fallback. +pub(super) fn sso_message_id(cx: &CallContext, action: impl Display) -> String { + if cx.request_id().is_empty() { + format!("truapi:sso:{action}") + } else { + cx.request_id().to_string() + } +} + +pub(super) fn fresh_statement_expiry() -> u64 { + let timestamp = current_unix_secs().saturating_add(DEFAULT_SSO_STATEMENT_EXPIRY_SECS); + timestamp << 32 +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_support::sso_session_info; + use futures::stream; + + #[test] + fn sso_remote_response_waiter_reports_timeout_cancellation() { + let session = sso_session_info(); + let cancel = CancellationToken::new(); + cancel.cancel_with_reason(CancellationReason::TimedOut { + timeout: Duration::from_millis(1), + }); + let err = futures::executor::block_on(wait_for_sso_remote_response(RemoteResponseWait { + own_statements: stream::pending().boxed(), + peer_statements: stream::pending().boxed(), + submit: futures::future::pending().boxed(), + session: session.sso.as_ref().unwrap(), + statement_request_id: "request-1", + remote_message_id: "request-1", + cancel: &cancel, + disconnect: None, + })) + .unwrap_err(); + + let SsoRemoteResponseError::Cancelled(err) = err else { + panic!("expected cancellation error"); + }; + assert_eq!( + err.to_string(), + "SSO response timed out after 1ms for request-1" + ); + } + + #[test] + fn sso_remote_response_waiter_reports_submit_rejections() { + let session = sso_session_info(); + let err = futures::executor::block_on(wait_for_sso_remote_response(RemoteResponseWait { + own_statements: stream::pending().boxed(), + peer_statements: stream::pending().boxed(), + submit: futures::future::ready(Err(SsoRemoteResponseError::Failure( + "SSO statement submit failed: no allowance".to_string(), + ))) + .boxed(), + session: session.sso.as_ref().unwrap(), + statement_request_id: "request-1", + remote_message_id: "request-1", + cancel: &CancellationToken::new(), + disconnect: None, + })) + .unwrap_err(); + + assert_eq!( + err, + SsoRemoteResponseError::Failure( + "SSO statement submit failed: no allowance".to_string() + ) + ); + } + + #[test] + fn sso_remote_response_waiter_stops_on_local_disconnect_signal() { + let session = sso_session_info(); + let (tx, rx) = oneshot::channel(); + tx.send(SSO_LOCAL_DISCONNECT_REASON.to_string()).unwrap(); + let err = futures::executor::block_on(wait_for_sso_remote_response(RemoteResponseWait { + own_statements: stream::pending().boxed(), + peer_statements: stream::pending().boxed(), + submit: futures::future::pending().boxed(), + session: session.sso.as_ref().unwrap(), + statement_request_id: "request-1", + remote_message_id: "request-1", + cancel: &CancellationToken::new(), + disconnect: Some(rx), + })) + .unwrap_err(); + + assert_eq!(err, SsoRemoteResponseError::LocalDisconnected); + } + + #[test] + fn sso_remote_response_waiter_without_timeout_stops_on_local_disconnect_signal() { + let session = sso_session_info(); + let (tx, rx) = oneshot::channel(); + tx.send(SSO_LOCAL_DISCONNECT_REASON.to_string()).unwrap(); + let err = futures::executor::block_on(wait_for_sso_remote_response(RemoteResponseWait { + own_statements: stream::pending().boxed(), + peer_statements: stream::pending().boxed(), + submit: futures::future::pending().boxed(), + session: session.sso.as_ref().unwrap(), + statement_request_id: "request-1", + remote_message_id: "request-1", + cancel: &CancellationToken::new(), + disconnect: Some(rx), + })) + .unwrap_err(); + + assert_eq!(err, SsoRemoteResponseError::LocalDisconnected); + } + + #[test] + fn sso_remote_response_waiter_stops_on_call_cancellation() { + let session = sso_session_info(); + let cancel = CancellationToken::new(); + let wait = wait_for_sso_remote_response(RemoteResponseWait { + own_statements: stream::pending().boxed(), + peer_statements: stream::pending().boxed(), + submit: futures::future::pending().boxed(), + session: session.sso.as_ref().unwrap(), + statement_request_id: "request-1", + remote_message_id: "request-1", + cancel: &cancel, + disconnect: None, + }); + + cancel.cancel(); + let err = futures::executor::block_on(wait).unwrap_err(); + + let SsoRemoteResponseError::Cancelled(err) = err else { + panic!("expected cancellation error"); + }; + assert_eq!( + err.to_string(), + "SSO response wait cancelled by caller for request-1" + ); + } +} diff --git a/rust/crates/truapi-server/src/runtime/statement_store.rs b/rust/crates/truapi-server/src/runtime/statement_store.rs new file mode 100644 index 00000000..2d7b42d3 --- /dev/null +++ b/rust/crates/truapi-server/src/runtime/statement_store.rs @@ -0,0 +1,694 @@ +//! `StatementStore` surface: session-key statement proofs plus submit and +//! subscribe flows over the people-chain statement store. + +use core::pin::Pin; +use core::task::{Context, Poll}; + +use super::statement_store_rpc::{self, StatementStoreRpc}; +use super::{ProductRuntimeHost, REMOTE_PERMISSION_DENIED_REASON}; +use crate::host_logic::statement_store::{ + MAX_MATCH_ALL_TOPICS, MAX_MATCH_ANY_TOPICS, TopicFilterKind, decode_signed_statement, + parse_new_statements_result, sign_statement_fields, signed_statement_to_scale, + statement_fields_from_v01, statement_proof_to_v01, +}; + +use serde_json::Value; +use subxt_rpcs::client::RpcSubscription; +use tracing::instrument; +use truapi::api::StatementStore; +use truapi::v01; +use truapi::versioned::statement_store::{ + RemoteStatementStoreCreateProofAuthorizedError, + RemoteStatementStoreCreateProofAuthorizedRequest, + RemoteStatementStoreCreateProofAuthorizedResponse, RemoteStatementStoreCreateProofError, + RemoteStatementStoreCreateProofRequest, RemoteStatementStoreCreateProofResponse, + RemoteStatementStoreSubmitError, RemoteStatementStoreSubmitRequest, + RemoteStatementStoreSubscribeError, RemoteStatementStoreSubscribeItem, + RemoteStatementStoreSubscribeRequest, +}; +use truapi::{CallContext, CallError, Subscription}; + +impl StatementStore for ProductRuntimeHost { + #[instrument(skip_all, fields(runtime.method = "statement_store.subscribe"))] + async fn subscribe( + &self, + _cx: &CallContext, + request: RemoteStatementStoreSubscribeRequest, + ) -> Result< + Subscription, + CallError, + > { + let (kind, topics) = match statement_store_topic_filter(request) { + Ok(value) => value, + Err(reason) => { + return Err(CallError::Domain(RemoteStatementStoreSubscribeError::V1( + v01::GenericError { reason }, + ))); + } + }; + let statement_store = self.statement_store_rpc(); + let rpc_client = statement_store + .client("statement-store") + .await + .map_err(|reason| { + CallError::Domain(RemoteStatementStoreSubscribeError::V1(v01::GenericError { + reason, + })) + })?; + let subscription = statement_store_rpc::subscribe(&rpc_client, kind, &topics) + .await + .map_err(|err| { + CallError::Domain(RemoteStatementStoreSubscribeError::V1(v01::GenericError { + reason: format!("statement-store subscribe failed: {err}"), + })) + })?; + let Some(remote_subscription_id) = subscription.subscription_id().map(ToString::to_string) + else { + return Err(CallError::Domain(RemoteStatementStoreSubscribeError::V1( + v01::GenericError { + reason: "statement-store subscribe returned no subscription id".to_string(), + }, + ))); + }; + let stream = statement_store_subscription_stream(subscription, remote_subscription_id); + Ok(Subscription::new(Box::pin(stream))) + } + + #[instrument(skip_all, fields(runtime.method = "statement_store.create_proof"))] + async fn create_proof( + &self, + _cx: &CallContext, + request: RemoteStatementStoreCreateProofRequest, + ) -> Result< + RemoteStatementStoreCreateProofResponse, + CallError, + > { + let RemoteStatementStoreCreateProofRequest::V1(mut inner) = request; + inner.product_account_id = Self::normalize_product_account_id(inner.product_account_id) + .map_err(|()| { + CallError::Domain(RemoteStatementStoreCreateProofError::V1( + v01::RemoteStatementStoreCreateProofError::UnknownAccount, + )) + })?; + if !self.is_product_account_valid_for_caller(&inner.product_account_id.dot_ns_identifier) { + return Err(CallError::Domain(RemoteStatementStoreCreateProofError::V1( + v01::RemoteStatementStoreCreateProofError::UnknownAccount, + ))); + } + let proof = self + .create_statement_proof(inner.statement) + .map_err(statement_proof_error)?; + Ok(RemoteStatementStoreCreateProofResponse::V1( + v01::RemoteStatementStoreCreateProofResponse { proof }, + )) + } + + #[instrument(skip_all, fields(runtime.method = "statement_store.create_proof_authorized"))] + async fn create_proof_authorized( + &self, + _cx: &CallContext, + request: RemoteStatementStoreCreateProofAuthorizedRequest, + ) -> Result< + RemoteStatementStoreCreateProofAuthorizedResponse, + CallError, + > { + let RemoteStatementStoreCreateProofAuthorizedRequest::V1(statement) = request; + let proof = self + .create_statement_proof(statement) + .map_err(statement_proof_authorized_error)?; + Ok(RemoteStatementStoreCreateProofAuthorizedResponse::V1( + v01::RemoteStatementStoreCreateProofResponse { proof }, + )) + } + + #[instrument(skip_all, fields(runtime.method = "statement_store.submit"))] + async fn submit( + &self, + _cx: &CallContext, + request: RemoteStatementStoreSubmitRequest, + ) -> Result<(), CallError> { + let RemoteStatementStoreSubmitRequest::V1(statement) = request; + self.require_remote_permission( + v01::RemotePermission::StatementSubmit, + RemoteStatementStoreSubmitError::V1(v01::GenericError { + reason: REMOTE_PERMISSION_DENIED_REASON.to_string(), + }), + ) + .await?; + let statement = signed_statement_to_scale(statement).map_err(|reason| { + CallError::Domain(RemoteStatementStoreSubmitError::V1(v01::GenericError { + reason, + })) + })?; + self.statement_store_rpc() + .submit(statement, "statement-store") + .await + .map_err(|reason| { + CallError::Domain(RemoteStatementStoreSubmitError::V1(v01::GenericError { + reason: format!("statement-store submit failed: {reason}"), + })) + }) + } +} + +fn statement_store_topic_filter( + request: RemoteStatementStoreSubscribeRequest, +) -> Result<(TopicFilterKind, Vec<[u8; 32]>), String> { + match request { + RemoteStatementStoreSubscribeRequest::V1( + v01::RemoteStatementStoreSubscribeRequest::MatchAll(topics), + ) => { + if topics.len() > MAX_MATCH_ALL_TOPICS { + return Err(format!( + "MatchAll has {} topics, maximum is {}", + topics.len(), + MAX_MATCH_ALL_TOPICS + )); + } + Ok((TopicFilterKind::MatchAll, topics)) + } + RemoteStatementStoreSubscribeRequest::V1( + v01::RemoteStatementStoreSubscribeRequest::MatchAny(topics), + ) => { + if topics.len() > MAX_MATCH_ANY_TOPICS { + let topic_count = topics.len(); + return Err(format!( + "MatchAny has {topic_count} topics, maximum is {MAX_MATCH_ANY_TOPICS}" + )); + } + Ok((TopicFilterKind::MatchAny, topics)) + } + } +} + +#[instrument(skip_all, fields(runtime.method = "statement_store.subscription_stream"))] +fn statement_store_subscription_stream( + subscription: RpcSubscription, + remote_subscription_id: String, +) -> impl futures::Stream + Send { + StatementStoreSubscriptionStream { + subscription, + remote_subscription_id, + is_complete: false, + } +} + +struct StatementStoreSubscriptionStream { + subscription: RpcSubscription, + remote_subscription_id: String, + is_complete: bool, +} + +impl futures::Stream for StatementStoreSubscriptionStream { + type Item = RemoteStatementStoreSubscribeItem; + + fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + let state = self.get_mut(); + loop { + let value = match Pin::new(&mut state.subscription).poll_next(cx) { + Poll::Pending => return Poll::Pending, + Poll::Ready(Some(Ok(value))) => value, + Poll::Ready(Some(Err(_))) | Poll::Ready(None) => { + return Poll::Ready(None); + } + }; + let page = + match parse_new_statements_result(state.remote_subscription_id.clone(), &value) { + Ok(page) => page, + Err(_) => continue, + }; + + let was_complete = state.is_complete; + let is_complete = was_complete || page.remaining == Some(0); + state.is_complete = is_complete; + let statements = page + .statements + .into_iter() + .filter_map(|statement| decode_signed_statement(&statement).ok()) + .collect::>(); + if statements.is_empty() { + if is_complete && !was_complete { + return Poll::Ready(Some(RemoteStatementStoreSubscribeItem::V1( + v01::RemoteStatementStoreSubscribeItem { + statements, + is_complete, + }, + ))); + } + continue; + } + + return Poll::Ready(Some(RemoteStatementStoreSubscribeItem::V1( + v01::RemoteStatementStoreSubscribeItem { + statements, + is_complete, + }, + ))); + } + } +} + +impl ProductRuntimeHost { + /// `StatementStoreRpc` bound to this runtime's people chain. + pub(super) fn statement_store_rpc(&self) -> StatementStoreRpc { + self.services.statement_store.clone() + } + + fn create_statement_proof( + &self, + statement: v01::Statement, + ) -> Result { + let session = self + .authority + .session_state() + .current() + .ok_or(StatementProofFailure::NoSession)?; + let sso = session + .sso + .as_ref() + .ok_or(StatementProofFailure::NoSession)?; + let fields = statement_fields_from_v01(statement) + .map_err(StatementProofFailure::InvalidStatement)?; + let signed = sign_statement_fields(sso.ss_secret, sso.ss_public_key, fields) + .map_err(StatementProofFailure::UnableToSign)?; + signed + .into_iter() + .find_map(|field| match field { + crate::host_logic::statement_store::StatementField::Proof(proof) => { + Some(statement_proof_to_v01(proof)) + } + _ => None, + }) + .ok_or_else(|| StatementProofFailure::UnableToSign("missing proof".to_string())) + } +} + +enum StatementProofFailure { + NoSession, + InvalidStatement(String), + UnableToSign(String), +} + +fn statement_proof_v01_error( + failure: StatementProofFailure, +) -> v01::RemoteStatementStoreCreateProofError { + match failure { + StatementProofFailure::NoSession => v01::RemoteStatementStoreCreateProofError::UnableToSign, + StatementProofFailure::UnableToSign(_reason) => { + v01::RemoteStatementStoreCreateProofError::UnableToSign + } + StatementProofFailure::InvalidStatement(reason) => { + v01::RemoteStatementStoreCreateProofError::Unknown { reason } + } + } +} + +fn statement_proof_error( + failure: StatementProofFailure, +) -> CallError { + CallError::Domain(RemoteStatementStoreCreateProofError::V1( + statement_proof_v01_error(failure), + )) +} + +fn statement_proof_authorized_error( + failure: StatementProofFailure, +) -> CallError { + CallError::Domain(RemoteStatementStoreCreateProofAuthorizedError::V1( + statement_proof_v01_error(failure), + )) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_support::{ + StubPlatform, account_id, new_statements_frame, runtime_config, signed_statement, + sso_session_info, statement, stub_platform, subscribe_ack_frame, test_spawner, + }; + use futures::StreamExt; + use parity_scale_codec::Encode; + use std::sync::Arc; + + #[test] + fn statement_store_create_proof_signs_with_session_key() { + let host = + ProductRuntimeHost::new(stub_platform(), runtime_config("myapp.dot"), test_spawner()); + let session = sso_session_info(); + let expected_signer = session.sso.as_ref().unwrap().ss_public_key; + host.test_session_state().set_session(session); + let cx = CallContext::new(); + let request = RemoteStatementStoreCreateProofRequest::V1( + v01::RemoteStatementStoreCreateProofRequest { + product_account_id: account_id("myapp.dot", 0), + statement: statement(), + }, + ); + + let response = + futures::executor::block_on(StatementStore::create_proof(&host, &cx, request)).unwrap(); + + let RemoteStatementStoreCreateProofResponse::V1(inner) = response; + let v01::StatementProof::Sr25519 { signer, signature } = inner.proof else { + panic!("expected sr25519 statement proof"); + }; + assert_eq!(signer, expected_signer); + assert_ne!(signature, [0; 64]); + } + + #[test] + fn statement_store_create_proof_rejects_wrong_product_account() { + let host = + ProductRuntimeHost::new(stub_platform(), runtime_config("myapp.dot"), test_spawner()); + host.test_session_state().set_session(sso_session_info()); + let cx = CallContext::new(); + let request = RemoteStatementStoreCreateProofRequest::V1( + v01::RemoteStatementStoreCreateProofRequest { + product_account_id: account_id("other.dot", 0), + statement: statement(), + }, + ); + + let err = futures::executor::block_on(StatementStore::create_proof(&host, &cx, request)) + .unwrap_err(); + + assert!(matches!( + err, + CallError::Domain(RemoteStatementStoreCreateProofError::V1( + v01::RemoteStatementStoreCreateProofError::UnknownAccount + )) + )); + } + + #[test] + fn statement_store_create_proof_authorized_signs_with_session_key() { + let host = + ProductRuntimeHost::new(stub_platform(), runtime_config("myapp.dot"), test_spawner()); + let session = sso_session_info(); + let expected_signer = session.sso.as_ref().unwrap().ss_public_key; + host.test_session_state().set_session(session); + let cx = CallContext::new(); + let request = RemoteStatementStoreCreateProofAuthorizedRequest::V1(statement()); + + let response = futures::executor::block_on(StatementStore::create_proof_authorized( + &host, &cx, request, + )) + .unwrap(); + + let RemoteStatementStoreCreateProofAuthorizedResponse::V1(inner) = response; + let v01::StatementProof::Sr25519 { signer, .. } = inner.proof else { + panic!("expected sr25519 statement proof"); + }; + assert_eq!(signer, expected_signer); + } + + #[test] + fn statement_store_submit_posts_signed_statement_and_waits_for_ack() { + let platform = Arc::new(StubPlatform { + rpc_responses: vec![r#"{"jsonrpc":"2.0","id":"truapi:1","result":"0xok"}"#.to_string()], + ..Default::default() + }); + let host = ProductRuntimeHost::new( + platform.clone(), + runtime_config("myapp.dot"), + test_spawner(), + ); + let cx = CallContext::with_request_id("submit-1".to_string()); + let request = RemoteStatementStoreSubmitRequest::V1(signed_statement([7; 32])); + + futures::executor::block_on(StatementStore::submit(&host, &cx, request)).unwrap(); + + let sent = platform.sent_rpc.lock().expect("rpc list mutex poisoned"); + assert_eq!(sent.len(), 1); + let request: serde_json::Value = serde_json::from_str(&sent[0]).unwrap(); + assert_eq!(request["method"], "statement_submit"); + let statement_hex = request["params"][0].as_str().unwrap(); + let statement = + hex::decode(statement_hex.strip_prefix("0x").unwrap_or(statement_hex)).unwrap(); + assert_eq!( + crate::host_logic::statement_store::decode_signed_statement(&statement).unwrap(), + signed_statement([7; 32]) + ); + } + + #[test] + fn statement_store_submit_requires_remote_permission_before_rpc() { + let platform = Arc::new(StubPlatform { + remote_permission_denied: true, + ..Default::default() + }); + let host = ProductRuntimeHost::new( + platform.clone(), + runtime_config("myapp.dot"), + test_spawner(), + ); + let cx = CallContext::with_request_id("submit-1".to_string()); + let request = RemoteStatementStoreSubmitRequest::V1(signed_statement([7; 32])); + + let err = + futures::executor::block_on(StatementStore::submit(&host, &cx, request)).unwrap_err(); + + match err { + CallError::Domain(RemoteStatementStoreSubmitError::V1(v01::GenericError { + reason, + })) => assert_eq!(reason, REMOTE_PERMISSION_DENIED_REASON), + other => panic!("expected statement-store permission denial, got {other:?}"), + } + assert!(platform.sent_rpc.lock().unwrap().is_empty()); + } + + #[test] + fn statement_store_subscribe_maps_signed_pages() { + let signed = crate::host_logic::statement_store::signed_statement_to_scale( + signed_statement([7; 32]), + ) + .unwrap(); + let unsigned = vec![crate::host_logic::statement_store::StatementField::Data( + vec![1], + )] + .encode(); + let platform = Arc::new(StubPlatform { + rpc_responses: vec![ + subscribe_ack_frame("truapi:1", "remote-sub"), + new_statements_frame("remote-sub", vec![unsigned, signed]), + ], + ..Default::default() + }); + let host = ProductRuntimeHost::new( + platform.clone(), + runtime_config("myapp.dot"), + test_spawner(), + ); + let cx = CallContext::with_request_id("sub-1".to_string()); + let mut subscription = futures::executor::block_on(StatementStore::subscribe( + &host, + &cx, + RemoteStatementStoreSubscribeRequest::V1( + v01::RemoteStatementStoreSubscribeRequest::MatchAny(vec![[7; 32]]), + ), + )) + .unwrap(); + + let item = futures::executor::block_on(subscription.next()).expect("statement page"); + + let RemoteStatementStoreSubscribeItem::V1(inner) = item; + assert!(inner.is_complete); + assert_eq!(inner.statements, vec![signed_statement([7; 32])]); + let sent = platform.sent_rpc.lock().expect("rpc list mutex poisoned"); + let request: serde_json::Value = serde_json::from_str(&sent[0]).unwrap(); + assert_eq!(request["method"], "statement_subscribeStatement"); + assert_eq!( + request["params"][0]["matchAny"][0], + "0x0707070707070707070707070707070707070707070707070707070707070707" + ); + } + + /// Pages that arrive before the subscribe ack are buffered by remote + /// subscription id and replayed once the ack confirms the subscription. + #[test] + fn statement_store_subscribe_buffers_pages_before_subscribe_ack() { + let rogue = crate::host_logic::statement_store::signed_statement_to_scale( + signed_statement([9; 32]), + ) + .unwrap(); + let signed = crate::host_logic::statement_store::signed_statement_to_scale( + signed_statement([7; 32]), + ) + .unwrap(); + let platform = Arc::new(StubPlatform { + rpc_responses: vec![ + new_statements_frame("remote-sub-pre", vec![rogue]), + subscribe_ack_frame("truapi:1", "remote-sub-pre"), + new_statements_frame("remote-sub-pre", vec![signed]), + ], + ..Default::default() + }); + let host = ProductRuntimeHost::new(platform, runtime_config("myapp.dot"), test_spawner()); + let cx = CallContext::with_request_id("sub-pre".to_string()); + let mut subscription = futures::executor::block_on(StatementStore::subscribe( + &host, + &cx, + RemoteStatementStoreSubscribeRequest::V1( + v01::RemoteStatementStoreSubscribeRequest::MatchAny(vec![[7; 32]]), + ), + )) + .unwrap(); + + let item = futures::executor::block_on(subscription.next()).expect("statement page"); + + assert_eq!( + item, + RemoteStatementStoreSubscribeItem::V1(v01::RemoteStatementStoreSubscribeItem { + statements: vec![signed_statement([9; 32])], + is_complete: true, + }) + ); + } + + #[test] + fn statement_store_subscribe_unsubscribes_remote_subscription_on_drop() { + let signed = crate::host_logic::statement_store::signed_statement_to_scale( + signed_statement([7; 32]), + ) + .unwrap(); + let platform = Arc::new(StubPlatform { + rpc_responses: vec![ + subscribe_ack_frame("truapi:1", "remote-sub-drop"), + new_statements_frame("remote-sub-drop", vec![signed]), + ], + ..Default::default() + }); + let host = ProductRuntimeHost::new( + platform.clone(), + runtime_config("myapp.dot"), + test_spawner(), + ); + let cx = CallContext::with_request_id("sub-drop".to_string()); + let mut subscription = futures::executor::block_on(StatementStore::subscribe( + &host, + &cx, + RemoteStatementStoreSubscribeRequest::V1( + v01::RemoteStatementStoreSubscribeRequest::MatchAny(vec![[7; 32]]), + ), + )) + .unwrap(); + + let _ = futures::executor::block_on(subscription.next()).expect("statement page"); + drop(subscription); + + let sent = platform.sent_rpc.lock().expect("rpc list mutex poisoned"); + assert_eq!(sent.len(), 2); + let unsubscribe: serde_json::Value = serde_json::from_str(&sent[1]).unwrap(); + assert_eq!(unsubscribe["method"], "statement_unsubscribeStatement"); + assert_eq!(unsubscribe["params"][0], "remote-sub-drop"); + } + + #[test] + fn statement_store_subscribe_emits_empty_completion_page_after_filtering() { + let unsigned = vec![crate::host_logic::statement_store::StatementField::Data( + vec![1], + )] + .encode(); + let platform = Arc::new(StubPlatform { + rpc_responses: vec![ + subscribe_ack_frame("truapi:1", "remote-sub-empty"), + new_statements_frame("remote-sub-empty", vec![unsigned]), + ], + ..Default::default() + }); + let host = ProductRuntimeHost::new(platform, runtime_config("myapp.dot"), test_spawner()); + let cx = CallContext::with_request_id("sub-empty-complete".to_string()); + let mut subscription = futures::executor::block_on(StatementStore::subscribe( + &host, + &cx, + RemoteStatementStoreSubscribeRequest::V1( + v01::RemoteStatementStoreSubscribeRequest::MatchAny(vec![[7; 32]]), + ), + )) + .unwrap(); + + let item = futures::executor::block_on(subscription.next()).expect("completion page"); + + let RemoteStatementStoreSubscribeItem::V1(inner) = item; + assert!(inner.is_complete); + assert!(inner.statements.is_empty()); + } + + #[test] + fn statement_store_subscribe_rejects_topic_limit_violations() { + let platform = stub_platform(); + let host = ProductRuntimeHost::new( + platform.clone(), + runtime_config("myapp.dot"), + test_spawner(), + ); + let cx = CallContext::with_request_id("sub-too-many".to_string()); + let topics = vec![[7; 32]; MAX_MATCH_ANY_TOPICS + 1]; + + let err = match futures::executor::block_on(StatementStore::subscribe( + &host, + &cx, + RemoteStatementStoreSubscribeRequest::V1( + v01::RemoteStatementStoreSubscribeRequest::MatchAny(topics), + ), + )) { + Ok(_) => panic!("topic limit violation should fail subscription start"), + Err(err) => err, + }; + + let CallError::Domain(RemoteStatementStoreSubscribeError::V1(reason)) = err else { + panic!("expected statement-store subscribe domain error"); + }; + assert_eq!( + reason.reason, + format!( + "MatchAny has {} topics, maximum is {}", + MAX_MATCH_ANY_TOPICS + 1, + MAX_MATCH_ANY_TOPICS + ) + ); + assert!(platform.sent_rpc.lock().unwrap().is_empty()); + } + + #[test] + fn statement_store_subscribe_reports_chain_connect_failure() { + let platform = Arc::new(StubPlatform { + chain_connect_error: Some("chain unavailable"), + ..Default::default() + }); + let host = ProductRuntimeHost::new( + platform.clone(), + runtime_config("myapp.dot"), + test_spawner(), + ); + let cx = CallContext::with_request_id("sub-connect-fail".to_string()); + + let err = match futures::executor::block_on(StatementStore::subscribe( + &host, + &cx, + RemoteStatementStoreSubscribeRequest::V1( + v01::RemoteStatementStoreSubscribeRequest::MatchAny(vec![[7; 32]]), + ), + )) { + Ok(_) => panic!("chain connect failure should fail subscription start"), + Err(err) => err, + }; + + let CallError::Domain(RemoteStatementStoreSubscribeError::V1(reason)) = err else { + panic!("expected statement-store subscribe domain error"); + }; + assert!( + reason + .reason + .contains("statement-store connect failed: GenericError"), + "unexpected reason: {}", + reason.reason + ); + assert!( + reason.reason.contains("chain unavailable"), + "unexpected reason: {}", + reason.reason + ); + assert!(platform.sent_rpc.lock().unwrap().is_empty()); + } +} diff --git a/rust/crates/truapi-server/src/runtime/statement_store_rpc.rs b/rust/crates/truapi-server/src/runtime/statement_store_rpc.rs new file mode 100644 index 00000000..bec77132 --- /dev/null +++ b/rust/crates/truapi-server/src/runtime/statement_store_rpc.rs @@ -0,0 +1,134 @@ +//! Runtime helper for People-chain statement-store JSON-RPC. + +use std::sync::Arc; + +use serde_json::{Value, json}; +use subxt_rpcs::RpcClient; +use subxt_rpcs::client::{RpcSubscription, rpc_params}; +use truapi_platform::{JsonRpcConnection, Platform}; + +use crate::host_logic::statement_store::{ + SUBMIT_STATEMENT_METHOD, SUBSCRIBE_STATEMENT_METHOD, TopicFilterKind, + UNSUBSCRIBE_STATEMENT_METHOD, hex_topic, +}; +use crate::host_rpc_client::HostRpcClient; +use crate::subscription::Spawner; + +/// People-chain statement-store RPC client factory. +#[derive(Clone)] +pub(crate) struct StatementStoreRpc { + platform: Arc, + people_chain_genesis_hash: [u8; 32], + spawner: Spawner, +} + +impl StatementStoreRpc { + /// Build a helper backed by the platform-owned chain provider. + pub(super) fn new( + platform: Arc, + people_chain_genesis_hash: [u8; 32], + spawner: Spawner, + ) -> Self { + Self { + platform, + people_chain_genesis_hash, + spawner, + } + } + + /// Open a statement-store RPC client over the host-provided People-chain + /// connection. + pub(super) async fn client(&self, label: &'static str) -> Result { + let connection = self.connect(label).await?; + Ok(RpcClient::new(HostRpcClient::new( + connection, + self.spawner.clone(), + ))) + } + + /// Submit a SCALE-encoded statement and wait for the JSON-RPC ack. + pub(super) async fn submit( + &self, + statement: Vec, + label: &'static str, + ) -> Result<(), String> { + let rpc_client = self.client(label).await?; + submit(&rpc_client, statement).await + } + + /// Submit a SCALE-encoded statement without waiting for the JSON-RPC ack. + pub(super) async fn submit_fire_and_forget( + &self, + statement: Vec, + label: &'static str, + ) -> Result<(), String> { + let connection = self.connect(label).await?; + HostRpcClient::new(connection, self.spawner.clone()) + .send_fire_and_forget( + SUBMIT_STATEMENT_METHOD, + rpc_params![format!("0x{}", hex::encode(&statement))].build(), + ) + .map_err(rpc_error_message) + } + + async fn connect(&self, label: &'static str) -> Result, String> { + self.platform + .connect(self.people_chain_genesis_hash) + .await + .map(Arc::from) + .map_err(|err| format!("{label} connect failed: {err:?}")) + } +} + +/// Subscribe to statements matching the requested topic filter. +pub(super) async fn subscribe( + rpc_client: &RpcClient, + kind: TopicFilterKind, + topics: &[[u8; 32]], +) -> Result, subxt_rpcs::Error> { + rpc_client + .subscribe::( + SUBSCRIBE_STATEMENT_METHOD, + rpc_params![filter(kind, topics)], + UNSUBSCRIBE_STATEMENT_METHOD, + ) + .await +} + +/// Subscribe to statements matching every topic. +pub(super) async fn subscribe_match_all( + rpc_client: &RpcClient, + topics: &[[u8; 32]], +) -> Result, subxt_rpcs::Error> { + subscribe(rpc_client, TopicFilterKind::MatchAll, topics).await +} + +/// Submit a SCALE-encoded statement and wait for the JSON-RPC ack. +pub(super) async fn submit(rpc_client: &RpcClient, statement: Vec) -> Result<(), String> { + rpc_client + .request::( + SUBMIT_STATEMENT_METHOD, + rpc_params![format!("0x{}", hex::encode(&statement))], + ) + .await + .map(|_| ()) + .map_err(rpc_error_message) +} + +/// Statement-store topic filter encoded as JSON-RPC params. +pub(super) fn filter(kind: TopicFilterKind, topics: &[[u8; 32]]) -> Value { + let topics = topics.iter().map(hex_topic).collect::>(); + match kind { + TopicFilterKind::MatchAll => json!({ "matchAll": topics }), + TopicFilterKind::MatchAny => json!({ "matchAny": topics }), + } +} + +/// Human-readable JSON-RPC error message, preserving user error text when +/// provided by the remote endpoint. +pub(super) fn rpc_error_message(error: subxt_rpcs::Error) -> String { + match error { + subxt_rpcs::Error::User(error) => error.message, + other => other.to_string(), + } +} diff --git a/rust/crates/truapi-server/src/subscription.rs b/rust/crates/truapi-server/src/subscription.rs index 8b24fd98..b6766d16 100644 --- a/rust/crates/truapi-server/src/subscription.rs +++ b/rust/crates/truapi-server/src/subscription.rs @@ -284,6 +284,25 @@ impl SubscriptionManager { None => {} } } + + /// Cancel and forget every pending or live subscription owned by this + /// manager. Used when the product runtime is disposed, where no further + /// frames should be emitted and platform resources must be released. + pub fn cancel_all(&self) { + let cancellations = { + let mut active = self.active.lock().unwrap(); + active + .drain() + .filter_map(|(_, slot)| match slot { + Slot::Pending { .. } => None, + Slot::Live { cancel, .. } => Some(cancel), + }) + .collect::>() + }; + for cancel in cancellations { + cancel(); + } + } } #[cfg(all(test, not(target_arch = "wasm32")))] @@ -291,6 +310,7 @@ mod tests { use super::*; use futures::stream; use std::sync::atomic::{AtomicUsize, Ordering}; + use std::task::{Context, Poll}; /// Transport that records every frame and notifies waiters when it /// reaches a target count. Used to wait for the subscription's @@ -347,6 +367,27 @@ mod tests { )) } + struct PendingDropStream { + dropped: Arc, + } + + impl Drop for PendingDropStream { + fn drop(&mut self) { + self.dropped.store(true, Ordering::SeqCst); + } + } + + impl futures::Stream for PendingDropStream { + type Item = SubscriptionOutput; + + fn poll_next( + self: std::pin::Pin<&mut Self>, + _cx: &mut Context<'_>, + ) -> Poll> { + Poll::Pending + } + } + /// Register a never-ending stream then immediately stop it. The /// stream's first poll must observe cancellation and exit without /// having pushed any frame. @@ -492,4 +533,31 @@ mod tests { "no leaked frames from the superseded stream" ); } + + #[test] + fn cancel_all_stops_live_subscription_streams() { + let transport_typed = Arc::new(RecordingTransport::new()); + let transport_dyn: Arc = transport_typed.clone(); + let manager = SubscriptionManager::new(thread_per_subscription_spawner()); + let dropped = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let stream: SubscriptionStream = Box::pin(PendingDropStream { + dropped: dropped.clone(), + }); + + manager.register("p:1".to_string(), 99, 98, stream, transport_dyn); + manager.cancel_all(); + + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); + while !dropped.load(Ordering::SeqCst) { + assert!( + std::time::Instant::now() < deadline, + "cancel_all did not drop the live subscription stream" + ); + std::thread::sleep(std::time::Duration::from_millis(5)); + } + assert!( + transport_typed.sent().is_empty(), + "runtime disposal cancellation must not emit an interrupt frame" + ); + } } diff --git a/rust/crates/truapi-server/src/test_support.rs b/rust/crates/truapi-server/src/test_support.rs new file mode 100644 index 00000000..62f2816f --- /dev/null +++ b/rust/crates/truapi-server/src/test_support.rs @@ -0,0 +1,1085 @@ +//! Shared fixtures for the runtime test modules: a stub platform, a +//! recording json-rpc connection, and SSO statement/frame builders. + +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex}; +use std::task::{Context, Poll}; + +#[cfg(not(target_arch = "wasm32"))] +use std::time::Duration; +#[cfg(target_arch = "wasm32")] +use web_time::Duration; + +use crate::host_logic::sso::pairing; +use crate::subscription::Spawner; +#[cfg(not(target_arch = "wasm32"))] +use crate::subscription::thread_per_subscription_spawner; + +use aes_gcm::aead::{Aead, KeyInit}; +use aes_gcm::{Aes256Gcm, Nonce}; +use futures::Stream; +use futures::stream::{self, BoxStream}; +use hkdf::Hkdf; +use p256::PublicKey as P256PublicKey; +use p256::SecretKey as P256SecretKey; +use p256::ecdh::diffie_hellman; +use p256::elliptic_curve::sec1::ToEncodedPoint; +use parity_scale_codec::{Decode, Encode}; +use schnorrkel::{ExpansionMode, MiniSecretKey}; +use sha2::Sha256; +use truapi::v01; +use truapi::versioned::account::HostAccountGetAliasRequest; +use truapi::versioned::resource_allocation::HostRequestResourceAllocationRequest; +use truapi_platform::{ + AuthPresenter, AuthState, ChainProvider, CoreStorage as PlatformCoreStorage, CoreStorageKey, + Features as PlatformFeatures, HostInfo, JsonRpcConnection, Navigation as PlatformNavigation, + Notifications as PlatformNotifications, PairingHostConfig, Permissions as PlatformPermissions, + PlatformInfo, PreimageHost, ProductContext, ProductStorage as PlatformProductStorage, + ThemeHost, UserConfirmation, UserConfirmationReview, +}; + +/// Test spawner that matches the current target. +pub(crate) fn test_spawner() -> Spawner { + #[cfg(not(target_arch = "wasm32"))] + { + thread_per_subscription_spawner() + } + #[cfg(target_arch = "wasm32")] + { + immediate_spawner() + } +} + +/// Synchronous spawner for tests that should complete work immediately. +#[cfg(target_arch = "wasm32")] +pub(crate) fn immediate_spawner() -> Spawner { + Arc::new(futures::executor::block_on) +} + +/// Test hook invoked after each recorded auth state. +pub type AuthStateHook = Arc; + +/// Minimal Platform impl that only answers `feature_supported`. Every +/// other callback returns a unit value or empty stream, so the runtime +/// can exercise its delegation paths without pulling in a real backend. +#[derive(Default)] +pub(crate) struct StubPlatform { + pub(crate) remote_permission_denied: bool, + pub(crate) account_alias_confirmed: bool, + pub(crate) account_alias_error: Option<&'static str>, + pub(crate) identity_disclosure_confirmed: bool, + pub(crate) identity_disclosure_error: Option<&'static str>, + pub(crate) identity_disclosure_calls: Arc, + pub(crate) sign_payload_confirmed: bool, + pub(crate) sign_payload_error: Option<&'static str>, + pub(crate) sign_raw_confirmed: bool, + pub(crate) sign_raw_error: Option<&'static str>, + pub(crate) create_transaction_confirmed: bool, + pub(crate) create_transaction_error: Option<&'static str>, + pub(crate) resource_allocation_confirmed: bool, + pub(crate) resource_allocation_error: Option<&'static str>, + pub(crate) session_blob: Option>, + pub(crate) session_error: Option<&'static str>, + pub(crate) session_clears: Arc>, + pub(crate) session_writes: Arc>>>, + /// Every `auth_state_changed` emission in order. + pub(crate) auth_states: Arc>>, + /// Invoked after each recorded auth state, outside any stub lock, so a + /// test can react to a transition (e.g. cancel the login it observes). + pub(crate) on_auth_state: Arc>>, + /// Set when a `chain_connect_pending` connect future is dropped, which is + /// how a dropped login flow manifests on the stub. + pub(crate) pending_connect_dropped: Arc, + /// When true, `subscribe_theme` returns a never-ending stream. + pub(crate) theme_stream_pending: bool, + /// Set when the pending theme stream is dropped. + pub(crate) theme_stream_dropped: Arc, + pub(crate) pairing_success_response: bool, + /// Deliver the pairing success statement only through a snapshot + /// query page; the live subscription stays silent. + pub(crate) pairing_success_via_query: bool, + pub(crate) notification_id: v01::NotificationId, + pub(crate) pushed_notifications: Arc>>, + pub(crate) cancelled_notifications: Arc>>, + pub(crate) sent_rpc: Arc>>, + pub(crate) rpc_responses: Vec, + pub(crate) chain_connect_error: Option<&'static str>, + pub(crate) chain_connect_pending: bool, + pub(crate) preimage_submits: Arc>>>, + pub(crate) local_storage: Arc>>>, + /// When set, product/core storage reads fail with this reason. + pub(crate) local_storage_error: Option<&'static str>, +} + +struct DropFlagGuard(Arc); + +impl Drop for DropFlagGuard { + fn drop(&mut self) { + self.0.store(true, Ordering::SeqCst); + } +} + +struct PendingThemeStream { + dropped: Arc, +} + +impl Drop for PendingThemeStream { + fn drop(&mut self) { + self.dropped.store(true, Ordering::SeqCst); + } +} + +impl Stream for PendingThemeStream { + type Item = Result; + + fn poll_next( + self: std::pin::Pin<&mut Self>, + _cx: &mut Context<'_>, + ) -> Poll> { + Poll::Pending + } +} + +/// First `Pairing` deeplink recorded on `auth_states`, if any. +pub(crate) fn first_pairing_deeplink(auth_states: &Mutex>) -> Option { + auth_states + .lock() + .expect("auth state list mutex poisoned") + .iter() + .find_map(|state| match state { + AuthState::Pairing { deeplink } => Some(deeplink.clone()), + _ => None, + }) +} + +/// Default stub platform wrapped in an `Arc`. +pub(crate) fn stub_platform() -> Arc { + Arc::new(StubPlatform::default()) +} + +/// Runtime configuration used by platform-backed runtime tests. +pub(crate) fn runtime_config(product_id: &str) -> (PairingHostConfig, ProductContext) { + ( + PairingHostConfig::new( + HostInfo { + name: "Polkadot Web".to_string(), + icon: Some("https://example.invalid/dotli.png".to_string()), + version: None, + }, + PlatformInfo::default(), + [0; 32], + "polkadotapp".to_string(), + ) + .expect("test host runtime config is valid"), + ProductContext::new(product_id.to_string()).expect("test product context is valid"), + ) +} + +/// Basic connected session fixture without SSO channel material. +pub(crate) fn session_info() -> crate::host_logic::session::SessionInfo { + crate::host_logic::session::SessionInfo { + public_key: [ + 0x80, 0x05, 0x28, 0xc9, 0x55, 0x87, 0x3e, 0x4c, 0x78, 0xb7, 0xdf, 0x24, 0xf7, 0x1d, + 0xb8, 0xf5, 0x81, 0xaa, 0x99, 0xe3, 0x49, 0x3b, 0xf4, 0x96, 0xed, 0xf1, 0x51, 0xab, + 0xc1, 0xd7, 0x20, 0x23, + ], + sso: None, + root_entropy_source: Some([ + 0x15, 0xcb, 0x94, 0x34, 0x84, 0x0b, 0x56, 0xbe, 0x1f, 0xdd, 0x91, 0xc4, 0x6a, 0x13, + 0xf5, 0x20, 0xf4, 0x91, 0x61, 0x2e, 0xa5, 0xd6, 0x06, 0x92, 0x0d, 0x91, 0x38, 0xe8, + 0xbd, 0xd6, 0x3c, 0xb0, + ]), + identity_account_id: Some([ + 0x80, 0x05, 0x28, 0xc9, 0x55, 0x87, 0x3e, 0x4c, 0x78, 0xb7, 0xdf, 0x24, 0xf7, 0x1d, + 0xb8, 0xf5, 0x81, 0xaa, 0x99, 0xe3, 0x49, 0x3b, 0xf4, 0x96, 0xed, 0xf1, 0x51, 0xab, + 0xc1, 0xd7, 0x20, 0x23, + ]), + lite_username: Some("alice".to_string()), + full_username: Some("Alice Smith".to_string()), + } +} + +/// Connected session fixture with deterministic SSO channel material. +pub(crate) fn sso_session_info() -> crate::host_logic::session::SessionInfo { + let mut session = session_info(); + let mini_secret = MiniSecretKey::from_bytes(&[7; 32]).unwrap(); + let keypair = mini_secret.expand_to_keypair(ExpansionMode::Ed25519); + let (_, peer_public_key) = peer_statement_keypair(); + let core_secret = P256SecretKey::from_slice(&[1; 32]).unwrap(); + let peer_secret = P256SecretKey::from_slice(&[2; 32]).unwrap(); + session.sso = Some(crate::host_logic::session::SsoSessionInfo { + ss_secret: keypair.secret.to_bytes(), + ss_public_key: keypair.public.to_bytes(), + enc_secret: core_secret.to_bytes().into(), + peer_enc_pubkey: peer_secret + .public_key() + .to_encoded_point(false) + .as_bytes() + .try_into() + .unwrap(), + identity_account_id: peer_public_key, + session_id_own: [4; 32], + session_id_peer: [5; 32], + request_channel: [6; 32], + response_channel: [7; 32], + peer_request_channel: [8; 32], + }); + session.root_entropy_source = Some(keypair.secret.to_bytes()[..32].try_into().unwrap()); + session +} + +/// Deterministic peer statement-store signing keypair. +pub(crate) fn peer_statement_keypair() -> ([u8; 64], [u8; 32]) { + let mini_secret = MiniSecretKey::from_bytes(&[9; 32]).unwrap(); + let keypair = mini_secret.expand_to_keypair(ExpansionMode::Ed25519); + (keypair.secret.to_bytes(), keypair.public.to_bytes()) +} + +/// SCALE-encoded statement signed by the deterministic peer keypair. +pub(crate) fn signed_test_statement(data: Vec) -> Vec { + let (secret, public) = peer_statement_keypair(); + crate::host_logic::statement_store::sign_statement_fields( + secret, + public, + vec![crate::host_logic::statement_store::StatementField::Data( + data, + )], + ) + .unwrap() + .encode() +} + +/// Last submitted SSO remote message decoded from the stub RPC log. +pub(crate) fn submitted_remote_message( + platform: &Arc, + session: &crate::host_logic::session::SessionInfo, +) -> crate::host_logic::sso::messages::RemoteMessage { + let submit = wait_for_statement_submit(&platform.sent_rpc); + let value: serde_json::Value = serde_json::from_str(&submit).unwrap(); + let statement_hex = value["params"][0].as_str().unwrap(); + let statement = hex::decode(statement_hex.strip_prefix("0x").unwrap_or(statement_hex)).unwrap(); + let encrypted = crate::host_logic::statement_store::decode_statement_data(&statement) + .expect("statement data should decode"); + let data = pairing::decrypt_session_statement_data(session.sso.as_ref().unwrap(), &encrypted) + .expect("statement data should decrypt"); + let pairing::SsoStatementData::Request { data, .. } = data else { + panic!("expected request statement data"); + }; + crate::host_logic::sso::messages::RemoteMessage::decode(&mut data[0].as_slice()) + .expect("remote message should decode") +} + +fn wait_for_statement_submit(sent: &Arc>>) -> String { + for _ in 0..100 { + if let Some(request) = sent + .lock() + .expect("rpc list mutex poisoned") + .iter() + .rev() + .find(|request| request.contains("\"statement_submit\"")) + .cloned() + { + return request; + } + #[cfg(not(target_arch = "wasm32"))] + std::thread::sleep(Duration::from_millis(1)); + #[cfg(target_arch = "wasm32")] + futures::executor::block_on(futures_timer::Delay::new(Duration::from_millis(1))); + } + panic!("statement_submit request should be sent"); +} + +/// JSON-RPC response sequence for a successful SSO request/response exchange. +pub(crate) fn sso_success_responses( + session: &crate::host_logic::session::SessionInfo, + message_id: &str, + response: crate::host_logic::sso::messages::RemoteMessage, +) -> Vec { + let own_subscription_id = format!("own-sub-{message_id}"); + let peer_subscription_id = format!("peer-sub-{message_id}"); + vec![ + subscribe_ack_frame("truapi:1", &own_subscription_id), + subscribe_ack_frame("truapi:2", &peer_subscription_id), + statement_submit_ack_frame("truapi:3"), + new_statements_frame( + &own_subscription_id, + vec![sso_statement( + session, + pairing::SsoStatementData::Response { + request_id: message_id.to_string(), + response_code: 0, + }, + 1, + )], + ), + new_statements_frame( + &peer_subscription_id, + vec![sso_statement( + session, + pairing::SsoStatementData::Request { + request_id: format!("wallet-response-{message_id}"), + data: vec![response.encode()], + }, + 2, + )], + ), + ] +} + +/// JSON-RPC response sequence where the SSO peer sends `Disconnected`. +pub(crate) fn sso_peer_disconnect_responses( + session: &crate::host_logic::session::SessionInfo, + message_id: &str, +) -> Vec { + let own_subscription_id = format!("own-sub-{message_id}"); + let peer_subscription_id = format!("peer-sub-{message_id}"); + vec![ + subscribe_ack_frame("truapi:1", &own_subscription_id), + subscribe_ack_frame("truapi:2", &peer_subscription_id), + statement_submit_ack_frame("truapi:3"), + new_statements_frame( + &own_subscription_id, + vec![sso_statement( + session, + pairing::SsoStatementData::Response { + request_id: message_id.to_string(), + response_code: 0, + }, + 1, + )], + ), + new_statements_frame( + &peer_subscription_id, + vec![sso_statement( + session, + pairing::SsoStatementData::Request { + request_id: format!("wallet-disconnect-{message_id}"), + data: vec![ + crate::host_logic::sso::messages::RemoteMessage { + message_id: format!("wallet-disconnect-{message_id}"), + data: crate::host_logic::sso::messages::RemoteMessageData::V1( + crate::host_logic::sso::messages::v1::RemoteMessage::Disconnected, + ), + } + .encode(), + ], + }, + 2, + )], + ), + ] +} + +/// JSON-RPC response sequence for the background peer-disconnect monitor. +pub(crate) fn sso_peer_disconnect_monitor_responses( + session: &crate::host_logic::session::SessionInfo, +) -> Vec { + let subscription_id = "peer-disconnect-monitor-sub"; + vec![ + subscribe_ack_frame("truapi:1", subscription_id), + new_statements_frame( + subscription_id, + vec![sso_statement( + session, + pairing::SsoStatementData::Request { + request_id: "wallet-disconnect-monitor".to_string(), + data: vec![ + crate::host_logic::sso::messages::RemoteMessage { + message_id: "wallet-disconnect-monitor".to_string(), + data: crate::host_logic::sso::messages::RemoteMessageData::V1( + crate::host_logic::sso::messages::v1::RemoteMessage::Disconnected, + ), + } + .encode(), + ], + }, + 1, + )], + ), + ] +} + +/// JSON-RPC subscription acknowledgement frame. +pub(crate) fn subscribe_ack_frame(request_id: &str, subscription_id: &str) -> String { + serde_json::json!({ + "jsonrpc": "2.0", + "id": request_id, + "result": subscription_id, + }) + .to_string() +} + +fn statement_submit_ack_frame(request_id: &str) -> String { + serde_json::json!({ + "jsonrpc": "2.0", + "id": request_id, + "result": "0xok", + }) + .to_string() +} + +/// JSON-RPC `newStatements` notification carrying SCALE statements. +pub(crate) fn new_statements_frame(subscription_id: &str, statements: Vec>) -> String { + let statements = statements + .into_iter() + .map(|statement| format!("0x{}", hex::encode(statement))) + .collect::>(); + serde_json::json!({ + "jsonrpc": "2.0", + "method": "statement_subscribeStatement", + "params": { + "subscription": subscription_id, + "result": { + "event": "newStatements", + "data": { + "statements": statements, + "remaining": 0, + }, + }, + }, + }) + .to_string() +} + +fn sso_statement( + session: &crate::host_logic::session::SessionInfo, + data: pairing::SsoStatementData, + nonce_seed: u8, +) -> Vec { + let mut nonce = [0; pairing::AES_GCM_NONCE_LEN]; + nonce[0] = nonce_seed; + let encrypted = pairing::encrypt_session_statement_data_with_nonce( + session.sso.as_ref().unwrap(), + &data, + nonce, + ) + .unwrap(); + signed_test_statement(encrypted) +} + +fn core_encryption_public_key_from_deeplink(deeplink: &str) -> [u8; 65] { + pairing_device_from_deeplink(deeplink).1 +} + +/// Pairing device statement and encryption keys encoded in a deeplink. +pub(crate) fn pairing_device_from_deeplink(deeplink: &str) -> ([u8; 32], [u8; 65]) { + let encoded = deeplink + .split("handshake=") + .nth(1) + .expect("pairing deeplink should include handshake"); + let handshake = hex::decode(encoded).expect("handshake should be hex"); + let decoded = pairing::VersionedHandshakeProposal::decode(&mut handshake.as_slice()) + .expect("handshake should decode"); + let pairing::VersionedHandshakeProposal::V2(proposal) = decoded; + ( + proposal.device.statement_account_id, + proposal.device.encryption_public_key, + ) +} + +pub(crate) fn wallet_handshake_statement(deeplink: &str) -> Vec { + let core_public_key = + P256PublicKey::from_sec1_bytes(&core_encryption_public_key_from_deeplink(deeplink)) + .expect("core encryption public key should decode"); + let wallet_ephemeral_secret = P256SecretKey::from_slice(&[3; 32]).unwrap(); + let wallet_ephemeral_public = wallet_ephemeral_secret.public_key().to_encoded_point(false); + let mut wallet_ephemeral_public_bytes = [0u8; 65]; + wallet_ephemeral_public_bytes.copy_from_slice(wallet_ephemeral_public.as_bytes()); + let wallet_persistent_public: [u8; 65] = P256SecretKey::from_slice(&[2; 32]) + .unwrap() + .public_key() + .to_encoded_point(false) + .as_bytes() + .try_into() + .unwrap(); + let answer = pairing::v2::EncryptedResponse::Success(Box::new(pairing::v2::Success { + identity_account_id: peer_statement_keypair().1, + root_account_id: session_info().public_key, + identity_chat_private_key: [0x77; 32], + sso_enc_pub_key: wallet_persistent_public, + device_enc_pub_key: wallet_persistent_public, + root_entropy_source: [0x66; 32], + })); + let shared_secret = diffie_hellman( + wallet_ephemeral_secret.to_nonzero_scalar(), + core_public_key.as_affine(), + ); + let hkdf = Hkdf::::new(None, shared_secret.raw_secret_bytes()); + let mut aes_key = [0u8; 32]; + hkdf.expand(&[], &mut aes_key).unwrap(); + let nonce = [0x44; pairing::AES_GCM_NONCE_LEN]; + let cipher = Aes256Gcm::new_from_slice(&aes_key).unwrap(); + let mut encrypted_message = nonce.to_vec(); + encrypted_message.extend( + cipher + .encrypt(Nonce::from_slice(&nonce), answer.encode().as_slice()) + .unwrap(), + ); + let handshake = pairing::VersionedHandshakeResponse::V2 { + encrypted_message, + public_key: wallet_ephemeral_public_bytes, + }; + + signed_test_statement(handshake.encode()) +} + +/// SSO signing response message for the given request id. +pub(crate) fn sign_response_message( + message_id: &str, + signature: Vec, + signed_transaction: Option>, +) -> crate::host_logic::sso::messages::RemoteMessage { + crate::host_logic::sso::messages::RemoteMessage { + message_id: format!("wallet-{message_id}"), + data: crate::host_logic::sso::messages::RemoteMessageData::V1( + crate::host_logic::sso::messages::v1::RemoteMessage::SignResponse( + crate::host_logic::sso::messages::SigningResponse { + responding_to: message_id.to_string(), + payload: Ok( + crate::host_logic::sso::messages::SigningPayloadResponseData { + signature, + signed_transaction, + }, + ), + }, + ), + ), + } +} + +/// Product account id fixture for `identifier` and derivation slot. +pub(crate) fn account_id(identifier: &str, derivation_index: u32) -> v01::ProductAccountId { + v01::ProductAccountId { + dot_ns_identifier: identifier.to_string(), + derivation_index, + } +} + +/// Account-alias request fixture for a product identifier. +pub(crate) fn account_alias_request(identifier: &str) -> HostAccountGetAliasRequest { + HostAccountGetAliasRequest::V1(v01::HostAccountGetAliasRequest { + product_account_id: account_id(identifier, 0), + }) +} + +/// Raw signing payload fixture. +pub(crate) fn raw_payload() -> v01::RawPayload { + v01::RawPayload::Bytes { + bytes: b"hello".to_vec(), + } +} + +/// Structured signing payload fixture. +pub(crate) fn sign_payload_data() -> v01::HostSignPayloadData { + v01::HostSignPayloadData { + block_hash: vec![0; 32], + block_number: vec![0; 4], + era: vec![0], + genesis_hash: vec![1; 32], + method: vec![0], + nonce: vec![0], + spec_version: vec![0], + tip: vec![0], + transaction_version: vec![0], + signed_extensions: vec![], + version: 4, + asset_id: None, + metadata_hash: None, + mode: None, + with_signed_transaction: None, + } +} + +/// Product transaction payload fixture for `identifier`. +pub(crate) fn product_tx_payload(identifier: &str) -> v01::ProductAccountTxPayload { + v01::ProductAccountTxPayload { + signer: account_id(identifier, 0), + genesis_hash: [1; 32], + call_data: vec![0], + extensions: vec![], + tx_ext_version: 0, + } +} + +/// Resource-allocation request fixture containing all supported resource kinds. +pub(crate) fn resource_allocation_request() -> HostRequestResourceAllocationRequest { + HostRequestResourceAllocationRequest::V1(v01::HostRequestResourceAllocationRequest { + resources: vec![ + v01::AllocatableResource::StatementStoreAllowance, + v01::AllocatableResource::AutoSigning, + ], + }) +} + +/// Unsigned statement fixture with channel, topics, expiry, and data. +pub(crate) fn statement() -> v01::Statement { + v01::Statement { + proof: None, + decryption_key: None, + expiry: Some(99), + channel: Some([1; 32]), + topics: vec![[2; 32], [3; 32]], + data: Some(vec![4, 5, 6]), + } +} + +/// Signed statement fixture scoped to `topic`. +pub(crate) fn signed_statement(topic: [u8; 32]) -> v01::SignedStatement { + v01::SignedStatement { + proof: v01::StatementProof::Sr25519 { + signature: [9; 64], + signer: [8; 32], + }, + decryption_key: None, + expiry: Some(99), + channel: Some([1; 32]), + topics: vec![topic], + data: Some(vec![4, 5, 6]), + } +} + +#[truapi_platform::async_trait] +impl PlatformProductStorage for StubPlatform { + async fn read(&self, key: String) -> Result>, v01::HostLocalStorageReadError> { + if let Some(reason) = self.local_storage_error { + return Err(v01::HostLocalStorageReadError::Unknown { + reason: reason.to_string(), + }); + } + Ok(self + .local_storage + .lock() + .expect("local storage mutex poisoned") + .get(&key) + .cloned()) + } + async fn write( + &self, + key: String, + value: Vec, + ) -> Result<(), v01::HostLocalStorageReadError> { + if let Some(reason) = self.local_storage_error { + return Err(v01::HostLocalStorageReadError::Unknown { + reason: reason.to_string(), + }); + } + self.local_storage + .lock() + .expect("local storage mutex poisoned") + .insert(key, value); + Ok(()) + } + async fn clear(&self, key: String) -> Result<(), v01::HostLocalStorageReadError> { + if let Some(reason) = self.local_storage_error { + return Err(v01::HostLocalStorageReadError::Unknown { + reason: reason.to_string(), + }); + } + self.local_storage + .lock() + .expect("local storage mutex poisoned") + .remove(&key); + Ok(()) + } +} + +#[truapi_platform::async_trait] +impl PlatformCoreStorage for StubPlatform { + async fn read_core_storage( + &self, + key: CoreStorageKey, + ) -> Result>, v01::GenericError> { + if let CoreStorageKey::AuthSession = key { + if let Some(reason) = self.session_error { + return Err(v01::GenericError { + reason: reason.to_string(), + }); + } + return Ok(self.session_blob.clone()); + } + if let Some(reason) = self.local_storage_error { + return Err(v01::GenericError { + reason: reason.to_string(), + }); + } + Ok(self + .local_storage + .lock() + .expect("local storage mutex poisoned") + .get(&core_storage_test_key(key)) + .cloned()) + } + + async fn write_core_storage( + &self, + key: CoreStorageKey, + value: Vec, + ) -> Result<(), v01::GenericError> { + if let CoreStorageKey::AuthSession = key { + self.session_writes + .lock() + .expect("session write list mutex poisoned") + .push(value); + return Ok(()); + } + if let Some(reason) = self.local_storage_error { + return Err(v01::GenericError { + reason: reason.to_string(), + }); + } + self.local_storage + .lock() + .expect("local storage mutex poisoned") + .insert(core_storage_test_key(key), value); + Ok(()) + } + + async fn clear_core_storage(&self, key: CoreStorageKey) -> Result<(), v01::GenericError> { + if let CoreStorageKey::AuthSession = key { + *self + .session_clears + .lock() + .expect("session clear counter mutex poisoned") += 1; + return Ok(()); + } + if let Some(reason) = self.local_storage_error { + return Err(v01::GenericError { + reason: reason.to_string(), + }); + } + self.local_storage + .lock() + .expect("local storage mutex poisoned") + .remove(&core_storage_test_key(key)); + Ok(()) + } +} + +/// Stable string key used by the stub core-storage map. +pub(crate) fn core_storage_test_key(key: CoreStorageKey) -> String { + format!("core:{}", hex::encode(key.encode())) +} + +#[truapi_platform::async_trait] +impl PlatformNavigation for StubPlatform { + async fn navigate_to(&self, _url: String) -> Result<(), v01::HostNavigateToError> { + Ok(()) + } +} + +#[truapi_platform::async_trait] +impl PlatformNotifications for StubPlatform { + async fn push_notification( + &self, + notification: v01::HostPushNotificationRequest, + ) -> Result { + self.pushed_notifications + .lock() + .expect("notification list mutex poisoned") + .push(notification); + Ok(v01::HostPushNotificationResponse { + id: self.notification_id, + }) + } + + async fn cancel_notification(&self, id: u32) -> Result<(), v01::GenericError> { + self.cancelled_notifications + .lock() + .expect("notification cancellation list mutex poisoned") + .push(id); + Ok(()) + } +} + +#[truapi_platform::async_trait] +impl PlatformPermissions for StubPlatform { + async fn device_permission( + &self, + _request: v01::HostDevicePermissionRequest, + ) -> Result { + Ok(v01::HostDevicePermissionResponse { granted: true }) + } + + async fn remote_permission( + &self, + _request: v01::RemotePermissionRequest, + ) -> Result { + Ok(v01::RemotePermissionResponse { + granted: !self.remote_permission_denied, + }) + } +} + +#[truapi_platform::async_trait] +impl PlatformFeatures for StubPlatform { + async fn feature_supported( + &self, + _request: v01::HostFeatureSupportedRequest, + ) -> Result { + Ok(v01::HostFeatureSupportedResponse { supported: true }) + } +} + +struct RecordingConnection { + sent: Arc>>, + responses: Vec, + auth_states: Arc>>, + pairing_success_response: bool, + pairing_success_via_query: bool, +} + +async fn wait_for_statement_subscribe_id(sent: Arc>>, index: usize) -> String { + for _ in 0..100 { + let ids = sent + .lock() + .expect("rpc list mutex poisoned") + .iter() + .filter_map(|request| { + let value: serde_json::Value = serde_json::from_str(request).ok()?; + (value.get("method")?.as_str()? == "statement_subscribeStatement") + .then(|| value.get("id")?.as_str().map(ToString::to_string))? + }) + .collect::>(); + if let Some(id) = ids.get(index) { + return id.clone(); + } + futures_timer::Delay::new(Duration::from_millis(1)).await; + } + panic!("statement_subscribeStatement request {index} was not issued"); +} + +impl JsonRpcConnection for RecordingConnection { + fn send(&self, request: String) { + self.sent + .lock() + .expect("rpc list mutex poisoned") + .push(request); + } + fn responses(&self) -> BoxStream<'static, String> { + if self.pairing_success_via_query { + let auth_states = self.auth_states.clone(); + let sent = self.sent.clone(); + return Box::pin(stream::unfold(0, move |state| { + let auth_states = auth_states.clone(); + let sent = sent.clone(); + async move { + match state { + 0 => { + let id = wait_for_statement_subscribe_id(sent.clone(), 0).await; + Some((subscribe_ack_frame(&id, "pairing-sub"), 1)) + } + 1 => { + let query_id = wait_for_statement_subscribe_id(sent.clone(), 1).await; + Some((subscribe_ack_frame(&query_id, "query-sub"), 2)) + } + 2 => { + for _ in 0..100 { + if let Some(deeplink) = first_pairing_deeplink(&auth_states) { + return Some(( + new_statements_frame( + "query-sub", + vec![wallet_handshake_statement(&deeplink)], + ), + 3, + )); + } + futures_timer::Delay::new(Duration::from_millis(1)).await; + } + panic!("pairing deeplink was not presented"); + } + _ => futures::future::pending().await, + } + } + })); + } + if self.pairing_success_response { + let auth_states = self.auth_states.clone(); + let sent = self.sent.clone(); + return Box::pin(stream::unfold(0, move |state| { + let auth_states = auth_states.clone(); + let sent = sent.clone(); + async move { + match state { + 0 => { + let id = wait_for_statement_subscribe_id(sent.clone(), 0).await; + Some((subscribe_ack_frame(&id, "pairing-sub"), 1)) + } + 1 => { + for _ in 0..100 { + if let Some(deeplink) = first_pairing_deeplink(&auth_states) { + return Some(( + new_statements_frame( + "pairing-sub", + vec![wallet_handshake_statement(&deeplink)], + ), + 2, + )); + } + futures_timer::Delay::new(Duration::from_millis(1)).await; + } + panic!("pairing deeplink was not presented"); + } + _ => futures::future::pending().await, + } + } + })); + } + if self.responses.is_empty() { + Box::pin(futures::stream::pending()) + } else { + let responses = self.responses.clone(); + let sent = self.sent.clone(); + Box::pin(stream::unfold(0, move |index| { + let responses = responses.clone(); + let sent = sent.clone(); + async move { + let Some(response) = responses.get(index).cloned() else { + return futures::future::pending().await; + }; + wait_for_matching_request_id(sent, &response).await; + Some((response, index + 1)) + } + })) + } + } + + fn close(&self) {} +} + +async fn wait_for_matching_request_id(sent: Arc>>, response: &str) { + let Some(id) = json_rpc_id(response) else { + return; + }; + for _ in 0..100 { + if sent + .lock() + .expect("rpc list mutex poisoned") + .iter() + .any(|request| json_rpc_id(request).as_deref() == Some(id.as_str())) + { + return; + } + futures_timer::Delay::new(Duration::from_millis(1)).await; + } + panic!("request {id} was not issued before scripted response"); +} + +fn json_rpc_id(frame: &str) -> Option { + let value: serde_json::Value = serde_json::from_str(frame).ok()?; + match value.get("id")? { + serde_json::Value::String(value) => Some(value.clone()), + serde_json::Value::Number(value) => Some(value.to_string()), + _ => None, + } +} + +#[truapi_platform::async_trait] +impl ChainProvider for StubPlatform { + async fn connect( + &self, + _genesis_hash: [u8; 32], + ) -> Result, v01::GenericError> { + if let Some(reason) = self.chain_connect_error { + return Err(v01::GenericError { + reason: reason.to_string(), + }); + } + if self.chain_connect_pending { + let _guard = DropFlagGuard(self.pending_connect_dropped.clone()); + futures::future::pending::<()>().await; + } + Ok(Box::new(RecordingConnection { + sent: self.sent_rpc.clone(), + responses: self.rpc_responses.clone(), + auth_states: self.auth_states.clone(), + pairing_success_response: self.pairing_success_response, + pairing_success_via_query: self.pairing_success_via_query, + })) + } +} + +impl AuthPresenter for StubPlatform { + fn auth_state_changed(&self, state: AuthState) { + self.auth_states + .lock() + .expect("auth state list mutex poisoned") + .push(state.clone()); + let hook = self + .on_auth_state + .lock() + .expect("auth state hook mutex poisoned") + .clone(); + if let Some(hook) = hook { + hook(&state); + } + } +} + +#[truapi_platform::async_trait] +impl UserConfirmation for StubPlatform { + async fn confirm_user_action( + &self, + review: UserConfirmationReview, + ) -> Result { + let (error, confirmed) = match review { + UserConfirmationReview::SignPayload(_) => { + (self.sign_payload_error, self.sign_payload_confirmed) + } + UserConfirmationReview::SignRaw(_) => (self.sign_raw_error, self.sign_raw_confirmed), + UserConfirmationReview::CreateTransaction(_) => ( + self.create_transaction_error, + self.create_transaction_confirmed, + ), + UserConfirmationReview::AccountAlias(_) => { + (self.account_alias_error, self.account_alias_confirmed) + } + UserConfirmationReview::IdentityDisclosure(_) => { + self.identity_disclosure_calls + .fetch_add(1, Ordering::SeqCst); + ( + self.identity_disclosure_error, + self.identity_disclosure_confirmed, + ) + } + UserConfirmationReview::ResourceAllocation(_) => ( + self.resource_allocation_error, + self.resource_allocation_confirmed, + ), + UserConfirmationReview::PreimageSubmit(_) => (None, true), + }; + if let Some(reason) = error { + return Err(v01::GenericError { + reason: reason.to_string(), + }); + } + Ok(confirmed) + } +} + +impl ThemeHost for StubPlatform { + fn subscribe_theme(&self) -> BoxStream<'static, Result> { + if self.theme_stream_pending { + return Box::pin(PendingThemeStream { + dropped: self.theme_stream_dropped.clone(), + }); + } + Box::pin(stream::once(async { Ok(v01::ThemeVariant::Dark) })) + } +} + +#[truapi_platform::async_trait] +impl PreimageHost for StubPlatform { + async fn submit_preimage(&self, value: Vec) -> Result, v01::PreimageSubmitError> { + self.preimage_submits + .lock() + .expect("preimage submit list mutex poisoned") + .push(value.clone()); + Ok(value) + } + fn lookup_preimage( + &self, + _key: Vec, + ) -> BoxStream<'static, Result>, v01::GenericError>> { + Box::pin(stream::once(async { Ok(Some(vec![9, 8, 7])) })) + } +} diff --git a/rust/crates/truapi-server/src/wasm.rs b/rust/crates/truapi-server/src/wasm.rs new file mode 100644 index 00000000..79dfd3a4 --- /dev/null +++ b/rust/crates/truapi-server/src/wasm.rs @@ -0,0 +1,911 @@ +//! wasm-bindgen surface. Exposes [`WasmProductRuntime`] to JavaScript hosts so +//! they can wire the TrUAPI core into a browser or worker shell. +//! +//! The browser side hands a `callbacks` object (a `JsBridge`) to the +//! constructor. The bridge implements every host-side capability the +//! [`truapi_platform::Platform`] trait set requires. Internally the bridge +//! is wrapped in a [`SendWrapper`] so it satisfies the `Send` bound the +//! platform trait set imposes; sound on wasm32 because the runtime is +//! single-threaded. + +use core::cell::Cell; +use core::future::Future; +use core::pin::Pin; +use core::task::{Context, Poll}; +use std::rc::Rc; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; + +use futures::channel::mpsc; +use futures::stream::{self, BoxStream, Stream, StreamExt}; +use js_sys::{Array, Function, Reflect, Uint8Array}; +use parity_scale_codec::Decode; +use send_wrapper::SendWrapper; +use truapi::v01; +use truapi_platform::{ + ChainProvider, HostInfo, JsonRpcConnection, PairingHostConfig, PlatformInfo, ProductContext, + RuntimeConfigValidationError, +}; +use wasm_bindgen::JsCast; +use wasm_bindgen::prelude::*; + +use crate::subscription::Spawner; +use crate::{ + FrameSink, PairingHostRuntime, PermissionAuthorizationRequest, PermissionAuthorizationStatus, + ProductRuntime, +}; + +mod generated_bridge; + +use generated_bridge::JsBridge; + +/// Per-core JS channel: outgoing frames and teardown for one product core. +struct CoreChannel { + emit_frame: Function, + dispose: Function, +} + +impl CoreChannel { + fn from_js(callbacks: &JsValue) -> Result { + Ok(Self { + emit_frame: get_function(callbacks, "emitFrame")?, + dispose: get_optional_function(callbacks, "dispose")?.unwrap_or_else(noop_function), + }) + } +} + +struct WasmFrameSink { + emit_frame: SendWrapper, +} + +impl FrameSink for WasmFrameSink { + fn emit_frame(&self, frame: Vec) { + let frame = Uint8Array::from(frame.as_slice()); + if let Err(err) = self.emit_frame.call1(&JsValue::NULL, &frame) { + web_sys::console::error_1(&err); + } + } +} + +struct WasmPlatform { + bridge: SendWrapper>, +} + +impl WasmPlatform { + fn new(bridge: Arc) -> Self { + Self { + bridge: SendWrapper::new(bridge), + } + } +} + +#[truapi_platform::async_trait] +impl ChainProvider for WasmPlatform { + async fn connect( + &self, + genesis_hash: [u8; 32], + ) -> Result, v01::GenericError> { + let chain_connect = self.bridge.chain_connect.clone(); + let chain_connect = SendWrapper::new(chain_connect); + SendWrapper::new(async move { + let (response_tx, response_rx) = mpsc::unbounded::(); + let on_response = Closure::wrap(Box::new(move |json: JsValue| { + // The host must hand back JSON-RPC frames as strings. Drop (and + // log) non-string values rather than forwarding an empty frame + // that would desync request/response correlation. + match json.as_string() { + Some(s) => { + let _ = response_tx.unbounded_send(s); + } + None => web_sys::console::error_1(&JsValue::from_str( + "chainConnect onResponse expected a JSON string; dropping non-string value", + )), + } + }) as Box); + + let genesis_arg = JsValue::from_str(&format!("0x{}", hex::encode(&genesis_hash))); + let returned = chain_connect + .call2( + &JsValue::NULL, + &genesis_arg, + on_response.as_ref().unchecked_ref(), + ) + .map_err(|err| generic(js_to_string(err)))?; + let resolved = await_optional_promise(returned).await.map_err(generic)?; + if resolved.is_null() || resolved.is_undefined() { + return Err(generic("chainConnect returned no connection".into())); + } + let send_fn = Reflect::get(&resolved, &JsValue::from_str("send")) + .map_err(|_| generic("chainConnect must return { send, close }".into()))? + .dyn_into::() + .map_err(|_| generic("chainConnect.send must be a function".into()))?; + let close_fn = Reflect::get(&resolved, &JsValue::from_str("close")) + .map_err(|_| generic("chainConnect.close must be a function".into()))? + .dyn_into::() + .map_err(|_| generic("chainConnect.close must be a function".into()))?; + + Ok(Box::new(JsCallbackJsonRpcConnection { + send_fn: SendWrapper::new(send_fn), + close_fn: SendWrapper::new(close_fn), + closed: AtomicBool::new(false), + _on_response: SendWrapper::new(on_response), + response_rx: std::sync::Mutex::new(Some(response_rx)), + }) as Box) + }) + .await + } +} + +// Account, signing, and statement-store flows live in the Rust core itself. +// The JS bridge only carries callbacks for platform capabilities the core +// cannot satisfy alone; account authority is selected by the runtime. + +struct JsSubscriptionStream { + rx: mpsc::UnboundedReceiver, + _send_item: SendWrapper>, + dispose: Option>, +} + +impl Stream for JsSubscriptionStream { + type Item = T; + + fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.rx).poll_next(cx) + } +} + +impl Drop for JsSubscriptionStream { + fn drop(&mut self) { + if let Some(dispose) = self.dispose.take() { + let _ = dispose.call0(&JsValue::NULL); + } + } +} + +fn invoke_js_subscription( + fn_: &Function, + payload: Option>, + parse_item: fn(JsValue) -> Result, +) -> BoxStream<'static, Result> +where + T: Send + 'static, +{ + let (tx, rx) = mpsc::unbounded::>(); + let send_item = Closure::wrap(Box::new(move |value: JsValue| { + let item = parse_item(value).map_err(generic); + let _ = tx.unbounded_send(item); + }) as Box); + + let call_result = match payload { + Some(payload) => { + let arg = Uint8Array::from(payload.as_slice()); + fn_.call2(&JsValue::NULL, &arg, send_item.as_ref().unchecked_ref()) + } + None => fn_.call1(&JsValue::NULL, send_item.as_ref().unchecked_ref()), + }; + + let dispose = match call_result { + Ok(value) if value.is_null() || value.is_undefined() => None, + Ok(value) => match value.dyn_into::() { + Ok(dispose) => Some(SendWrapper::new(dispose)), + Err(_) => { + return stream::once(async { + Err(generic( + "subscription callback must return a dispose function, null, or undefined" + .to_string(), + )) + }) + .boxed(); + } + }, + Err(err) => return stream::once(async { Err(generic(js_to_string(err))) }).boxed(), + }; + + Box::pin(JsSubscriptionStream { + rx, + _send_item: SendWrapper::new(send_item), + dispose, + }) +} + +struct JsCallbackJsonRpcConnection { + send_fn: SendWrapper, + close_fn: SendWrapper, + closed: AtomicBool, + /// Closure must outlive the connection so JS keeps a live ref to the + /// response sink. Dropped together with the rest of the struct. + _on_response: SendWrapper>, + response_rx: std::sync::Mutex>>, +} + +impl JsonRpcConnection for JsCallbackJsonRpcConnection { + fn send(&self, request: String) { + let arg = JsValue::from_str(&request); + if let Err(err) = self.send_fn.call1(&JsValue::NULL, &arg) { + web_sys::console::error_1(&err); + } + } + + /// Single-take: the response receiver is handed out exactly once. A second + /// call yields an empty stream (and logs), since the channel has one + /// consumer. + fn responses(&self) -> BoxStream<'static, String> { + let mut guard = self.response_rx.lock().unwrap(); + match guard.take() { + Some(rx) => rx.boxed(), + None => { + web_sys::console::error_1(&JsValue::from_str( + "JsCallbackJsonRpcConnection::responses() called more than once", + )); + futures::stream::empty().boxed() + } + } + } + + fn close(&self) { + if self.closed.swap(true, Ordering::AcqRel) { + return; + } + let _ = self.close_fn.call0(&JsValue::NULL); + } +} + +impl Drop for JsCallbackJsonRpcConnection { + fn drop(&mut self) { + self.close(); + } +} + +fn generic(reason: String) -> v01::GenericError { + v01::GenericError { reason } +} + +/// Await the JS callback's return value if it's a Promise; pass other +/// values through unchanged. Every host callback resolves through this so +/// the JS side is free to be sync or async. +async fn await_optional_promise(returned: JsValue) -> Result { + if returned.is_instance_of::() { + let promise = returned.unchecked_into::(); + wasm_bindgen_futures::JsFuture::from(promise) + .await + .map_err(js_to_string) + } else { + Ok(returned) + } +} + +fn call_js_function(fn_: &Function, args: &[JsValue]) -> Result { + let js_args = Array::new(); + for arg in args { + js_args.push(arg); + } + fn_.apply(&JsValue::NULL, &js_args).map_err(js_to_string) +} + +fn invoke_unit( + fn_: &Function, + args: Vec, +) -> impl Future> + Send { + let fn_ = fn_.clone(); + SendWrapper::new(async move { + let returned = call_js_function(&fn_, &args)?; + await_optional_promise(returned).await.map(|_| ()) + }) +} + +fn invoke_bool( + fn_: &Function, + args: Vec, +) -> impl Future> + Send { + let fn_ = fn_.clone(); + SendWrapper::new(async move { + let returned = call_js_function(&fn_, &args)?; + let resolved = await_optional_promise(returned).await?; + // A non-boolean resolved value is a host contract violation; surface it + // rather than silently masking it as `false` (which would read as a + // denial / unsupported and hide the host bug). + resolved + .as_bool() + .ok_or_else(|| "callback must resolve to a boolean".to_string()) + }) +} + +fn invoke_bytes_return( + fn_: &Function, + args: Vec, +) -> impl Future, String>> + Send { + let fn_ = fn_.clone(); + SendWrapper::new(async move { + let returned = call_js_function(&fn_, &args)?; + let resolved = await_optional_promise(returned).await?; + resolved + .dyn_into::() + .map(|array| array.to_vec()) + .map_err(|_| "callback must resolve to Uint8Array".to_string()) + }) +} + +fn invoke_optional_bytes_return( + fn_: &Function, + args: Vec, + expected: &'static str, +) -> impl Future>, String>> + Send { + let fn_ = fn_.clone(); + SendWrapper::new(async move { + let returned = call_js_function(&fn_, &args)?; + let resolved = await_optional_promise(returned).await?; + if resolved.is_null() || resolved.is_undefined() { + return Ok(None); + } + resolved + .dyn_into::() + .map(|array| Some(array.to_vec())) + .map_err(|_| expected.to_string()) + }) +} + +fn decode_bytes(bytes: Vec, message: &str) -> Result { + T::decode(&mut bytes.as_slice()).map_err(|_| message.to_string()) +} + +fn decode_js_item(value: JsValue, label: &str) -> Result { + let bytes = value + .dyn_into::() + .map_err(|_| format!("{label} item must be Uint8Array"))? + .to_vec(); + decode_bytes(bytes, &format!("encoded {label} item did not decode")) +} + +fn parse_optional_bytes_item(value: JsValue) -> Result>, String> { + if value.is_null() || value.is_undefined() { + return Ok(None); + } + value + .dyn_into::() + .map(|array| Some(array.to_vec())) + .map_err(|_| "optional bytes item must be Uint8Array, null, or undefined".to_string()) +} + +fn js_to_string(value: JsValue) -> String { + value + .as_string() + .or_else(|| { + value + .dyn_ref::() + .map(|err| err.message().into()) + }) + .unwrap_or_else(|| format!("{value:?}")) +} + +fn get_function(callbacks: &JsValue, name: &str) -> Result { + let value = Reflect::get(callbacks, &JsValue::from_str(name))?; + value + .dyn_into::() + .map_err(|_| JsValue::from_str(&format!("callbacks.{name} must be a function"))) +} + +fn get_optional_function(callbacks: &JsValue, name: &str) -> Result, JsValue> { + let value = Reflect::get(callbacks, &JsValue::from_str(name))?; + if value.is_null() || value.is_undefined() { + return Ok(None); + } + value + .dyn_into::() + .map(Some) + .map_err(|_| JsValue::from_str(&format!("callbacks.{name} must be a function"))) +} + +fn noop_function() -> Function { + Function::new_no_args("") +} + +fn runtime_config_from_js(value: &JsValue) -> Result<(PairingHostConfig, ProductContext), JsValue> { + let host_config = pairing_host_config_from_js(value)?; + let product = product_context_from_js(value)?; + Ok((host_config, product)) +} + +fn pairing_host_config_from_js(value: &JsValue) -> Result { + if value.is_null() || value.is_undefined() { + return Err(JsValue::from_str("hostConfig is required")); + } + + let host = get_required_object(value, "host", "runtimeConfig.host")?; + let platform = get_optional_object(value, "platform", "runtimeConfig.platform")?; + let people = get_required_object(value, "people", "runtimeConfig.people")?; + let pairing = get_required_object(value, "pairing", "runtimeConfig.pairing")?; + + PairingHostConfig::new( + HostInfo { + name: get_required_string_at(&host, "name", "runtimeConfig.host.name")?, + icon: get_optional_string_at(&host, "icon", "runtimeConfig.host.icon")?, + version: get_optional_string_at(&host, "version", "runtimeConfig.host.version")?, + }, + PlatformInfo { + kind: platform + .as_ref() + .map(|p| get_optional_string_at(p, "type", "runtimeConfig.platform.type")) + .transpose()? + .flatten(), + version: platform + .as_ref() + .map(|p| get_optional_string_at(p, "version", "runtimeConfig.platform.version")) + .transpose()? + .flatten(), + }, + get_required_bytes32_at(&people, "genesisHash", "runtimeConfig.people.genesisHash")?, + get_required_string_at( + &pairing, + "deeplinkScheme", + "runtimeConfig.pairing.deeplinkScheme", + )?, + ) + .map_err(runtime_config_validation_to_js) +} + +fn product_context_from_js(value: &JsValue) -> Result { + if value.is_null() || value.is_undefined() { + return Err(JsValue::from_str("product is required")); + } + ProductContext::new(get_required_string_at( + value, + "productId", + "runtimeConfig.productId", + )?) + .map_err(runtime_config_validation_to_js) +} + +fn runtime_config_field_to_js(field: &str) -> &str { + match field { + "product_id" => "productId", + "host_info.name" => "host.name", + "pairing_deeplink_scheme" => "pairing.deeplinkScheme", + "people_chain_genesis_hash" => "people.genesisHash", + other => other, + } +} + +fn runtime_config_validation_to_js(err: RuntimeConfigValidationError) -> JsValue { + match err { + RuntimeConfigValidationError::EmptyField { field } => JsValue::from_str(&format!( + "runtimeConfig.{} must not be empty", + runtime_config_field_to_js(field) + )), + RuntimeConfigValidationError::InvalidHostIcon { reason } => JsValue::from_str(&format!( + "runtimeConfig.host.icon must be an absolute HTTPS URL: {reason}" + )), + RuntimeConfigValidationError::InsecureHostIcon { scheme } => JsValue::from_str(&format!( + "runtimeConfig.host.icon must use https scheme, got {scheme:?}" + )), + RuntimeConfigValidationError::InvalidDeeplinkScheme { scheme } => JsValue::from_str( + &format!("runtimeConfig.pairing.deeplinkScheme must not include ://, got {scheme:?}"), + ), + RuntimeConfigValidationError::InvalidProductId { product_id } => { + JsValue::from_str(&format!( + "runtimeConfig.productId must be a .dot or localhost product identifier, got {product_id:?}" + )) + } + } +} + +fn get_required_object(value: &JsValue, name: &str, path: &str) -> Result { + let property = Reflect::get(value, &JsValue::from_str(name))?; + if property.is_null() || property.is_undefined() { + return Err(JsValue::from_str(&format!("{path} is required"))); + } + if !property.is_object() { + return Err(JsValue::from_str(&format!("{path} must be an object"))); + } + Ok(property) +} + +fn get_optional_object( + value: &JsValue, + name: &str, + path: &str, +) -> Result, JsValue> { + let property = Reflect::get(value, &JsValue::from_str(name))?; + if property.is_null() || property.is_undefined() { + return Ok(None); + } + if !property.is_object() { + return Err(JsValue::from_str(&format!("{path} must be an object"))); + } + Ok(Some(property)) +} + +fn get_optional_string_at( + value: &JsValue, + name: &str, + path: &str, +) -> Result, JsValue> { + let property = Reflect::get(value, &JsValue::from_str(name))?; + if property.is_null() || property.is_undefined() { + return Ok(None); + } + property + .as_string() + .map(Some) + .ok_or_else(|| JsValue::from_str(&format!("{path} must be a string"))) +} + +fn get_required_string_at(value: &JsValue, name: &str, path: &str) -> Result { + get_optional_string_at(value, name, path)? + .ok_or_else(|| JsValue::from_str(&format!("{path} is required"))) +} + +fn get_optional_bytes32_at( + value: &JsValue, + name: &str, + path: &str, +) -> Result, JsValue> { + let property = Reflect::get(value, &JsValue::from_str(name))?; + if property.is_null() || property.is_undefined() { + return Ok(None); + } + if let Some(hex) = property.as_string() { + return parse_hex32(&hex) + .map(Some) + .map_err(|reason| JsValue::from_str(&format!("{path}: {reason}"))); + } + let array = property + .dyn_into::() + .map_err(|_| JsValue::from_str(&format!("{path} must be hex or Uint8Array")))?; + let bytes = array.to_vec(); + bytes.try_into().map(Some).map_err(|bytes: Vec| { + JsValue::from_str(&format!( + "{path} must be exactly 32 bytes, got {}", + bytes.len() + )) + }) +} + +fn get_required_bytes32_at(value: &JsValue, name: &str, path: &str) -> Result<[u8; 32], JsValue> { + get_optional_bytes32_at(value, name, path)? + .ok_or_else(|| JsValue::from_str(&format!("{path} is required"))) +} + +fn parse_hex32(value: &str) -> Result<[u8; 32], String> { + let raw = value.strip_prefix("0x").unwrap_or(value); + if raw.len() != 64 { + return Err(format!( + "expected 32-byte hex string, got {} hex chars", + raw.len() + )); + } + let bytes = hex::decode(raw).map_err(|_| "invalid hex".to_string())?; + bytes + .try_into() + .map_err(|bytes: Vec| format!("expected 32 bytes, got {}", bytes.len())) +} + +fn decode_permission_authorization_request( + payload: &[u8], +) -> Result { + PermissionAuthorizationRequest::decode(&mut &*payload).map_err(|err| { + JsValue::from_str(&format!( + "permission authorization request did not decode: {err}" + )) + }) +} + +fn decode_permission_authorization_requests( + payloads: &Array, +) -> Result, JsValue> { + let mut requests = Vec::with_capacity(payloads.length() as usize); + for payload in payloads.iter() { + let payload = payload + .dyn_into::() + .map_err(|_| JsValue::from_str("permission authorization request must be bytes"))?; + requests.push(decode_permission_authorization_request(&payload.to_vec())?); + } + Ok(requests) +} + +fn permission_authorization_status_to_js(status: PermissionAuthorizationStatus) -> JsValue { + JsValue::from_str(match status { + PermissionAuthorizationStatus::NotDetermined => "NotDetermined", + PermissionAuthorizationStatus::Denied => "Denied", + PermissionAuthorizationStatus::Authorized => "Authorized", + }) +} + +fn permission_authorization_status_from_js( + status: &str, +) -> Result { + match status { + "NotDetermined" => Ok(PermissionAuthorizationStatus::NotDetermined), + "Denied" => Ok(PermissionAuthorizationStatus::Denied), + "Authorized" => Ok(PermissionAuthorizationStatus::Authorized), + other => Err(JsValue::from_str(&format!( + "unknown permission authorization status: {other}" + ))), + } +} + +fn generic_error_to_js(err: v01::GenericError) -> JsValue { + JsValue::from_str(&err.reason) +} + +struct WasmCoreInner { + core: ProductRuntime, + dispose_fn: SendWrapper, + disposed: Cell, + disposing: Cell, +} + +/// JS-callable handle to a long-lived pairing-host runtime shared by product +/// cores. +#[wasm_bindgen] +pub struct WasmPairingHostRuntime { + runtime: Rc, +} + +#[wasm_bindgen] +impl WasmPairingHostRuntime { + /// Build a shared runtime from host-level platform callbacks and host config. + #[wasm_bindgen(constructor)] + pub fn new( + callbacks: JsValue, + host_config: JsValue, + ) -> Result { + console_error_panic_hook::set_once(); + crate::logging::init(); + let bridge = Arc::new(JsBridge::from_js(&callbacks)?); + let platform = Arc::new(WasmPlatform::new(bridge)); + let spawner: Spawner = Arc::new(|fut| { + wasm_bindgen_futures::spawn_local(fut); + }); + let host_config = pairing_host_config_from_js(&host_config)?; + Ok(Self { + runtime: Rc::new(PairingHostRuntime::new(platform, host_config, spawner)), + }) + } + + /// Build one product-scoped runtime from this pairing host runtime. + #[wasm_bindgen(js_name = productRuntime)] + pub fn product_runtime( + &self, + product: JsValue, + core_callbacks: JsValue, + ) -> Result { + let product = product_context_from_js(&product)?; + let channel = CoreChannel::from_js(&core_callbacks)?; + let sink = Arc::new(WasmFrameSink { + emit_frame: SendWrapper::new(channel.emit_frame), + }); + let runtime = self.runtime.product_runtime(product, sink); + Ok(WasmProductRuntime::from_parts(runtime, channel.dispose)) + } + + /// Disconnect the shared account-authority session. + #[wasm_bindgen(js_name = disconnectSession)] + pub async fn disconnect_session(&self) { + self.runtime.disconnect_session().await; + } + + /// Cancel an in-flight pairing flow. + #[wasm_bindgen(js_name = cancelPairing)] + pub fn cancel_pairing(&self) { + self.runtime.cancel_pairing(); + } + + /// Notify the runtime that the auth session slot may have changed. + #[wasm_bindgen(js_name = notifySessionStoreChanged)] + pub fn notify_session_store_changed(&self) { + self.runtime.notify_session_store_changed(); + } + + /// Read a stored permission authorization status for a product. + #[wasm_bindgen(js_name = permissionAuthorizationStatus)] + pub async fn permission_authorization_status( + &self, + product_id: String, + payload: Vec, + ) -> Result { + let request = decode_permission_authorization_request(&payload)?; + let status = self + .runtime + .permission_authorization_status(&product_id, request) + .await + .map_err(generic_error_to_js)?; + Ok(permission_authorization_status_to_js(status)) + } + + /// Read stored permission authorization statuses for a product. + #[wasm_bindgen(js_name = permissionAuthorizationStatuses)] + pub async fn permission_authorization_statuses( + &self, + product_id: String, + payloads: Array, + ) -> Result { + let requests = decode_permission_authorization_requests(&payloads)?; + let statuses = self + .runtime + .permission_authorization_statuses(&product_id, requests) + .await + .map_err(generic_error_to_js)?; + let values = Array::new(); + for status in statuses { + values.push(&permission_authorization_status_to_js(status)); + } + Ok(values) + } + + /// Update a stored permission authorization status for a product. + #[wasm_bindgen(js_name = setPermissionAuthorizationStatus)] + pub async fn set_permission_authorization_status( + &self, + product_id: String, + payload: Vec, + status: String, + ) -> Result<(), JsValue> { + let request = decode_permission_authorization_request(&payload)?; + let status = permission_authorization_status_from_js(&status)?; + self.runtime + .set_permission_authorization_status(&product_id, request, status) + .await + .map_err(generic_error_to_js) + } +} + +/// Set the live log level (`off`/`error`/`warn`/`info`/`debug`/`trace`). +/// Hosts may call this during boot, or again at any time to re-tune verbosity. +/// Unknown values are parsed as `off`. +#[wasm_bindgen(js_name = setLogLevel)] +pub fn set_log_level(level: &str) { + crate::logging::set_level_from_str(level); +} + +/// JS-callable handle to one product-scoped TrUAPI core. +#[wasm_bindgen] +pub struct WasmProductRuntime { + inner: Rc, +} + +impl WasmProductRuntime { + fn from_parts(core: ProductRuntime, dispose_fn: Function) -> Self { + Self { + inner: Rc::new(WasmCoreInner { + core, + dispose_fn: SendWrapper::new(dispose_fn), + disposed: Cell::new(false), + disposing: Cell::new(false), + }), + } + } +} + +#[wasm_bindgen] +impl WasmProductRuntime { + /// Build the core from a JS callbacks object. The object must define + /// every host capability the [`truapi_platform::Platform`] trait set + /// requires (camelCase property names; see the source for the full + /// list). + #[wasm_bindgen(constructor)] + pub fn new(callbacks: JsValue, runtime_config: JsValue) -> Result { + // Surface Rust panics to the browser console. A panic mid-dispatch + // aborts the call as a wasm trap; the host should treat a thrown error + // from `receiveFrame` as a fatal-instance signal and rebuild the + // core rather than continue using it. + console_error_panic_hook::set_once(); + crate::logging::init(); + let bridge = Arc::new(JsBridge::from_js(&callbacks)?); + let channel = CoreChannel::from_js(&callbacks)?; + let frame_sink = Arc::new(WasmFrameSink { + emit_frame: SendWrapper::new(channel.emit_frame), + }); + let platform = Arc::new(WasmPlatform::new(bridge)); + let spawner: Spawner = Arc::new(|fut| { + wasm_bindgen_futures::spawn_local(fut); + }); + let (host_config, product) = runtime_config_from_js(&runtime_config)?; + let core = ProductRuntime::from_platform_with_config( + platform, + host_config, + product, + spawner, + frame_sink, + ); + Ok(Self::from_parts(core, channel.dispose)) + } + + /// Push a SCALE-encoded protocol frame into the dispatcher. Responses + /// (and subscription items) flow back through the `emitFrame` + /// callback. + #[wasm_bindgen(js_name = receiveFrame)] + pub async fn receive_frame(&self, frame: Vec) -> Result<(), JsValue> { + self.inner + .core + .receive_frame(frame) + .await + .map_err(|err| JsValue::from_str(&err.to_string())) + } + + /// Read a stored permission authorization status without prompting. + /// + /// `payload` is a SCALE-encoded `PermissionAuthorizationRequest`. + #[wasm_bindgen(js_name = permissionAuthorizationStatus)] + pub async fn permission_authorization_status( + &self, + payload: Vec, + ) -> Result { + let request = decode_permission_authorization_request(&payload)?; + let status = self + .inner + .core + .permission_authorization_status(request) + .await + .map_err(generic_error_to_js)?; + Ok(permission_authorization_status_to_js(status)) + } + + /// Read stored permission authorization statuses without prompting. + /// + /// `payloads` is an array of SCALE-encoded + /// `PermissionAuthorizationRequest` values. Results follow the same order. + #[wasm_bindgen(js_name = permissionAuthorizationStatuses)] + pub async fn permission_authorization_statuses( + &self, + payloads: Array, + ) -> Result { + let requests = decode_permission_authorization_requests(&payloads)?; + let statuses = self + .inner + .core + .permission_authorization_statuses(requests) + .await + .map_err(generic_error_to_js)?; + let values = Array::new(); + for status in statuses { + values.push(&permission_authorization_status_to_js(status)); + } + Ok(values) + } + + /// Update a stored permission authorization status. Passing + /// `"NotDetermined"` clears the stored value so the next product request + /// prompts again. + #[wasm_bindgen(js_name = setPermissionAuthorizationStatus)] + pub async fn set_permission_authorization_status( + &self, + payload: Vec, + status: String, + ) -> Result<(), JsValue> { + let request = decode_permission_authorization_request(&payload)?; + let status = permission_authorization_status_from_js(&status)?; + self.inner + .core + .set_permission_authorization_status(request, status) + .await + .map_err(generic_error_to_js) + } + + /// Tear down the bridge. Invokes the JS-side `dispose` callback so the + /// host can drop its end of the wiring. + pub fn dispose(&self) -> Result<(), JsValue> { + if self.inner.disposed.get() { + return Ok(()); + } + if self.inner.disposing.replace(true) { + return Ok(()); + } + + self.inner.core.dispose(); + + let result = self.inner.dispose_fn.call0(&JsValue::NULL).map(|_| ()); + + self.inner.disposed.set(true); + self.inner.disposing.set(false); + result + } + + /// Core-owned logout/disconnect. Best-effort notifies the SSO peer when + /// the session has channel material, then clears in-memory and persisted + /// session state. + #[wasm_bindgen(js_name = disconnectSession)] + pub async fn disconnect_session(&self) -> Result<(), JsValue> { + self.inner.core.disconnect_session().await; + Ok(()) + } +} diff --git a/rust/crates/truapi-server/src/wasm/generated_bridge.rs b/rust/crates/truapi-server/src/wasm/generated_bridge.rs new file mode 100644 index 00000000..50f2614b --- /dev/null +++ b/rust/crates/truapi-server/src/wasm/generated_bridge.rs @@ -0,0 +1,300 @@ +//! Auto-generated by truapi-codegen. Do not edit. +//! +//! Mechanical wasm-bindgen callback bridge derived from +//! `truapi-platform`. Raw callback names and payload shapes match the +//! generated TypeScript host-callback adapter. + +use futures::stream::BoxStream; +use js_sys::{Function, Uint8Array}; +use parity_scale_codec::Encode; +use truapi::v01; +use wasm_bindgen::JsValue; + +use super::{ + WasmPlatform, call_js_function, decode_bytes, decode_js_item, generic, get_function, + invoke_bool, invoke_bytes_return, invoke_js_subscription, invoke_optional_bytes_return, + invoke_unit, parse_optional_bytes_item, +}; + +/// JS-side callbacks invoked by the wasm platform bridge. Methods with +/// Rust default bodies are still required here because the generated TS +/// adapter resolves optional host callbacks before constructing this +/// raw callback object. +pub(super) struct JsBridge { + pub(super) auth_state_changed: Function, + pub(super) chain_connect: Function, + pub(super) read_core_storage: Function, + pub(super) write_core_storage: Function, + pub(super) clear_core_storage: Function, + pub(super) feature_supported: Function, + pub(super) navigate_to: Function, + pub(super) push_notification: Function, + pub(super) cancel_notification: Function, + pub(super) device_permission: Function, + pub(super) remote_permission: Function, + pub(super) submit_preimage: Function, + pub(super) lookup_preimage: Function, + pub(super) read: Function, + pub(super) write: Function, + pub(super) clear: Function, + pub(super) subscribe_theme: Function, + pub(super) confirm_user_action: Function, +} + +impl JsBridge { + pub(super) fn from_js(callbacks: &JsValue) -> Result { + Ok(Self { + auth_state_changed: get_function(callbacks, "authStateChanged")?, + chain_connect: get_function(callbacks, "chainConnect")?, + read_core_storage: get_function(callbacks, "readCoreStorage")?, + write_core_storage: get_function(callbacks, "writeCoreStorage")?, + clear_core_storage: get_function(callbacks, "clearCoreStorage")?, + feature_supported: get_function(callbacks, "featureSupported")?, + navigate_to: get_function(callbacks, "navigateTo")?, + push_notification: get_function(callbacks, "pushNotification")?, + cancel_notification: get_function(callbacks, "cancelNotification")?, + device_permission: get_function(callbacks, "devicePermission")?, + remote_permission: get_function(callbacks, "remotePermission")?, + submit_preimage: get_function(callbacks, "submitPreimage")?, + lookup_preimage: get_function(callbacks, "lookupPreimage")?, + read: get_function(callbacks, "read")?, + write: get_function(callbacks, "write")?, + clear: get_function(callbacks, "clear")?, + subscribe_theme: get_function(callbacks, "subscribeTheme")?, + confirm_user_action: get_function(callbacks, "confirmUserAction")?, + }) + } +} + +impl truapi_platform::AuthPresenter for WasmPlatform { + fn auth_state_changed(&self, state: truapi_platform::AuthState) { + if let Err(reason) = call_js_function( + &self.bridge.auth_state_changed, + &vec![Uint8Array::from(state.encode().as_slice()).into()], + ) { + web_sys::console::error_1(&JsValue::from_str(&reason)); + } + } +} + +#[truapi_platform::async_trait] +impl truapi_platform::CoreStorage for WasmPlatform { + async fn read_core_storage( + &self, + key: truapi_platform::CoreStorageKey, + ) -> Result>, v01::GenericError> { + invoke_optional_bytes_return( + &self.bridge.read_core_storage, + vec![Uint8Array::from(key.encode().as_slice()).into()], + "readCoreStorage must resolve to Uint8Array, null or undefined", + ) + .await + .map_err(generic) + } + + async fn write_core_storage( + &self, + key: truapi_platform::CoreStorageKey, + value: Vec, + ) -> Result<(), v01::GenericError> { + invoke_unit( + &self.bridge.write_core_storage, + vec![ + Uint8Array::from(key.encode().as_slice()).into(), + Uint8Array::from(value.as_slice()).into(), + ], + ) + .await + .map_err(generic) + } + + async fn clear_core_storage( + &self, + key: truapi_platform::CoreStorageKey, + ) -> Result<(), v01::GenericError> { + invoke_unit( + &self.bridge.clear_core_storage, + vec![Uint8Array::from(key.encode().as_slice()).into()], + ) + .await + .map_err(generic) + } +} + +#[truapi_platform::async_trait] +impl truapi_platform::Features for WasmPlatform { + async fn feature_supported( + &self, + request: v01::HostFeatureSupportedRequest, + ) -> Result { + let bytes = invoke_bytes_return( + &self.bridge.feature_supported, + vec![Uint8Array::from(request.encode().as_slice()).into()], + ) + .await + .map_err(generic)?; + decode_bytes::( + bytes, + "featureSupported response did not decode", + ) + .map_err(generic) + } +} + +#[truapi_platform::async_trait] +impl truapi_platform::Navigation for WasmPlatform { + async fn navigate_to(&self, url: String) -> Result<(), v01::HostNavigateToError> { + invoke_unit(&self.bridge.navigate_to, vec![JsValue::from_str(&url)]) + .await + .map_err(|reason| v01::HostNavigateToError::Unknown { reason }) + } +} + +#[truapi_platform::async_trait] +impl truapi_platform::Notifications for WasmPlatform { + async fn push_notification( + &self, + notification: v01::HostPushNotificationRequest, + ) -> Result { + let bytes = invoke_bytes_return( + &self.bridge.push_notification, + vec![Uint8Array::from(notification.encode().as_slice()).into()], + ) + .await + .map_err(generic)?; + decode_bytes::( + bytes, + "pushNotification response did not decode", + ) + .map_err(generic) + } + + async fn cancel_notification(&self, id: v01::NotificationId) -> Result<(), v01::GenericError> { + invoke_unit( + &self.bridge.cancel_notification, + vec![JsValue::from_f64(f64::from(id))], + ) + .await + .map_err(generic) + } +} + +#[truapi_platform::async_trait] +impl truapi_platform::Permissions for WasmPlatform { + async fn device_permission( + &self, + request: v01::HostDevicePermissionRequest, + ) -> Result { + let bytes = invoke_bytes_return( + &self.bridge.device_permission, + vec![Uint8Array::from(request.encode().as_slice()).into()], + ) + .await + .map_err(generic)?; + decode_bytes::( + bytes, + "devicePermission response did not decode", + ) + .map_err(generic) + } + + async fn remote_permission( + &self, + request: v01::RemotePermissionRequest, + ) -> Result { + let bytes = invoke_bytes_return( + &self.bridge.remote_permission, + vec![Uint8Array::from(request.encode().as_slice()).into()], + ) + .await + .map_err(generic)?; + decode_bytes::( + bytes, + "remotePermission response did not decode", + ) + .map_err(generic) + } +} + +#[truapi_platform::async_trait] +impl truapi_platform::PreimageHost for WasmPlatform { + async fn submit_preimage(&self, value: Vec) -> Result, v01::PreimageSubmitError> { + invoke_bytes_return( + &self.bridge.submit_preimage, + vec![Uint8Array::from(value.as_slice()).into()], + ) + .await + .map_err(|reason| v01::PreimageSubmitError::Unknown { reason }) + } + + fn lookup_preimage( + &self, + key: Vec, + ) -> BoxStream<'static, Result>, v01::GenericError>> { + invoke_js_subscription( + &self.bridge.lookup_preimage, + Some(key), + parse_optional_bytes_item, + ) + } +} + +#[truapi_platform::async_trait] +impl truapi_platform::ProductStorage for WasmPlatform { + async fn read(&self, key: String) -> Result>, v01::HostLocalStorageReadError> { + invoke_optional_bytes_return( + &self.bridge.read, + vec![JsValue::from_str(&key)], + "read must resolve to Uint8Array, null or undefined", + ) + .await + .map_err(|reason| v01::HostLocalStorageReadError::Unknown { reason }) + } + + async fn write( + &self, + key: String, + value: Vec, + ) -> Result<(), v01::HostLocalStorageReadError> { + invoke_unit( + &self.bridge.write, + vec![ + JsValue::from_str(&key), + Uint8Array::from(value.as_slice()).into(), + ], + ) + .await + .map_err(|reason| v01::HostLocalStorageReadError::Unknown { reason }) + } + + async fn clear(&self, key: String) -> Result<(), v01::HostLocalStorageReadError> { + invoke_unit(&self.bridge.clear, vec![JsValue::from_str(&key)]) + .await + .map_err(|reason| v01::HostLocalStorageReadError::Unknown { reason }) + } +} + +impl truapi_platform::ThemeHost for WasmPlatform { + fn subscribe_theme(&self) -> BoxStream<'static, Result> { + invoke_js_subscription(&self.bridge.subscribe_theme, None, parse_theme_variant_item) + } +} + +#[truapi_platform::async_trait] +impl truapi_platform::UserConfirmation for WasmPlatform { + async fn confirm_user_action( + &self, + review: truapi_platform::UserConfirmationReview, + ) -> Result { + invoke_bool( + &self.bridge.confirm_user_action, + vec![Uint8Array::from(review.encode().as_slice()).into()], + ) + .await + .map_err(generic) + } +} + +fn parse_theme_variant_item(value: JsValue) -> Result { + decode_js_item::(value, "ThemeVariant") +} diff --git a/rust/crates/truapi-server/tests/common/mod.rs b/rust/crates/truapi-server/tests/common/mod.rs new file mode 100644 index 00000000..8d0743cd --- /dev/null +++ b/rust/crates/truapi-server/tests/common/mod.rs @@ -0,0 +1,202 @@ +#[cfg(target_arch = "wasm32")] +use std::sync::Arc; + +use std::sync::Mutex; + +use futures::stream::{self, BoxStream}; +use truapi::v01; +use truapi_platform::{ + AuthPresenter, ChainProvider, CoreStorage, CoreStorageKey, Features, HostInfo, + JsonRpcConnection, Navigation, Notifications, PairingHostConfig, Permissions, PlatformInfo, + PreimageHost, ProductContext, ProductStorage, ThemeHost, UserConfirmation, + UserConfirmationReview, +}; +use truapi_server::frame::ProtocolMessage; +use truapi_server::transport::Transport; + +/// Transport stub that records every frame sent through it, for asserting +/// what the core emits during a dispatch. +#[derive(Default)] +pub struct RecordingTransport { + /// Frames captured in send order. + pub sent: Mutex>, +} + +impl Transport for RecordingTransport { + fn send(&self, message: ProtocolMessage) { + self.sent.lock().unwrap().push(message); + } + fn on_message( + &self, + _handler: Box, + ) -> Box { + Box::new(|| {}) + } +} + +/// Test spawner that matches the current target. +pub fn test_spawner() -> truapi_server::subscription::Spawner { + #[cfg(not(target_arch = "wasm32"))] + { + truapi_server::subscription::thread_per_subscription_spawner() + } + #[cfg(target_arch = "wasm32")] + { + Arc::new(futures::executor::block_on) + } +} + +/// Runtime configuration shared by integration tests. +pub fn test_runtime_config() -> (PairingHostConfig, ProductContext) { + ( + PairingHostConfig::new( + HostInfo { + name: "Polkadot Web".to_string(), + icon: Some("https://dot.li/dotli.png".to_string()), + version: None, + }, + PlatformInfo::default(), + [0xa2; 32], + "polkadotapp".to_string(), + ) + .expect("test host runtime config is valid"), + ProductContext::new("dotli.dot".to_string()).expect("test product context is valid"), + ) +} + +pub struct WireShapePlatform; + +#[truapi_platform::async_trait] +impl ProductStorage for WireShapePlatform { + async fn read(&self, _key: String) -> Result>, v01::HostLocalStorageReadError> { + Err(v01::HostLocalStorageReadError::Full) + } + async fn write( + &self, + _key: String, + _value: Vec, + ) -> Result<(), v01::HostLocalStorageReadError> { + Ok(()) + } + async fn clear(&self, _key: String) -> Result<(), v01::HostLocalStorageReadError> { + Ok(()) + } +} + +#[truapi_platform::async_trait] +impl Navigation for WireShapePlatform { + async fn navigate_to(&self, _url: String) -> Result<(), v01::HostNavigateToError> { + Ok(()) + } +} + +#[truapi_platform::async_trait] +impl Notifications for WireShapePlatform { + async fn push_notification( + &self, + _notification: v01::HostPushNotificationRequest, + ) -> Result { + Ok(v01::HostPushNotificationResponse { id: 0 }) + } + + async fn cancel_notification(&self, _id: u32) -> Result<(), v01::GenericError> { + Ok(()) + } +} + +#[truapi_platform::async_trait] +impl Permissions for WireShapePlatform { + async fn device_permission( + &self, + _request: v01::HostDevicePermissionRequest, + ) -> Result { + Ok(v01::HostDevicePermissionResponse { granted: true }) + } + async fn remote_permission( + &self, + _request: v01::RemotePermissionRequest, + ) -> Result { + Ok(v01::RemotePermissionResponse { granted: true }) + } +} + +#[truapi_platform::async_trait] +impl Features for WireShapePlatform { + async fn feature_supported( + &self, + _request: v01::HostFeatureSupportedRequest, + ) -> Result { + Ok(v01::HostFeatureSupportedResponse { supported: true }) + } +} + +struct DeadConnection; + +impl JsonRpcConnection for DeadConnection { + fn send(&self, _request: String) {} + fn responses(&self) -> BoxStream<'static, String> { + Box::pin(stream::empty()) + } + fn close(&self) {} +} + +#[truapi_platform::async_trait] +impl ChainProvider for WireShapePlatform { + async fn connect( + &self, + _genesis_hash: [u8; 32], + ) -> Result, v01::GenericError> { + Ok(Box::new(DeadConnection)) + } +} + +impl AuthPresenter for WireShapePlatform {} + +#[truapi_platform::async_trait] +impl CoreStorage for WireShapePlatform { + async fn read_core_storage( + &self, + _key: CoreStorageKey, + ) -> Result>, v01::GenericError> { + Ok(None) + } + async fn write_core_storage( + &self, + _key: CoreStorageKey, + _value: Vec, + ) -> Result<(), v01::GenericError> { + Ok(()) + } + async fn clear_core_storage(&self, _key: CoreStorageKey) -> Result<(), v01::GenericError> { + Ok(()) + } +} + +#[truapi_platform::async_trait] +impl UserConfirmation for WireShapePlatform { + async fn confirm_user_action( + &self, + _review: UserConfirmationReview, + ) -> Result { + Ok(false) + } +} + +impl ThemeHost for WireShapePlatform { + fn subscribe_theme(&self) -> BoxStream<'static, Result> { + Box::pin(stream::empty()) + } +} + +#[truapi_platform::async_trait] +impl PreimageHost for WireShapePlatform { + async fn submit_preimage(&self, value: Vec) -> Result, v01::PreimageSubmitError> { + Ok(value) + } + fn lookup_preimage( + &self, + _key: Vec, + ) -> BoxStream<'static, Result>, v01::GenericError>> { + Box::pin(stream::empty()) + } +} diff --git a/rust/crates/truapi-server/tests/wasm_crypto_vectors.rs b/rust/crates/truapi-server/tests/wasm_crypto_vectors.rs new file mode 100644 index 00000000..80f42931 --- /dev/null +++ b/rust/crates/truapi-server/tests/wasm_crypto_vectors.rs @@ -0,0 +1,226 @@ +#![cfg(target_arch = "wasm32")] + +use aes_gcm::aead::{Aead, KeyInit}; +use aes_gcm::{Aes256Gcm, Nonce}; +use hkdf::Hkdf; +use p256::SecretKey; +use p256::ecdh::diffie_hellman; +use p256::elliptic_curve::sec1::ToEncodedPoint; +use parity_scale_codec::{Decode, Encode}; +use schnorrkel::{ExpansionMode, MiniSecretKey}; +use sha2::Sha256; +use truapi_platform::{HostInfo, PairingHostConfig, PlatformInfo}; +use truapi_server::host_logic::entropy::derive_product_entropy; +use truapi_server::host_logic::product_account::{ + derive_product_public_key, product_public_key_to_address, +}; +use truapi_server::host_logic::session::SsoSessionInfo; +use truapi_server::host_logic::sso::pairing::{ + self, AES_GCM_NONCE_LEN, PairingBootstrap, SsoStatementData, VersionedHandshakeProposal, + VersionedHandshakeResponse, bootstrap_topic, build_pairing_deeplink, decode_app_handshake_data, + decrypt_session_statement_data, decrypt_v2_handshake_response, + encrypt_session_statement_data_with_nonce, establish_sso_session_info, +}; +use truapi_server::host_logic::statement_store::{ + build_signed_session_request_statement, decode_verified_statement_data, +}; +use wasm_bindgen_test::wasm_bindgen_test; + +const ROOT_PUBLIC_KEY: [u8; 32] = [ + 0x80, 0x05, 0x28, 0xc9, 0x55, 0x87, 0x3e, 0x4c, 0x78, 0xb7, 0xdf, 0x24, 0xf7, 0x1d, 0xb8, 0xf5, + 0x81, 0xaa, 0x99, 0xe3, 0x49, 0x3b, 0xf4, 0x96, 0xed, 0xf1, 0x51, 0xab, 0xc1, 0xd7, 0x20, 0x23, +]; + +const SS_PUBLIC: [u8; 32] = [ + 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, + 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f, +]; + +const ENC_PUBLIC: [u8; 65] = [ + 0x04, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, + 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, + 0x1f, 0x20, 0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27, 0x28, 0x29, 0x2a, 0x2b, 0x2c, 0x2d, 0x2e, + 0x2f, 0x30, 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3a, 0x3b, 0x3c, 0x3d, 0x3e, + 0x3f, +]; + +fn entropy_secret() -> [u8; 32] { + std::array::from_fn(|i| i as u8) +} + +fn runtime_config() -> PairingHostConfig { + PairingHostConfig::new( + HostInfo { + name: "Polkadot Web".to_string(), + icon: Some("https://example.invalid/dotli.png".to_string()), + version: Some("1.2.3".to_string()), + }, + PlatformInfo { + kind: Some("Firefox".to_string()), + version: Some("192.32".to_string()), + }, + [0xa2; 32], + "polkadotapp".to_string(), + ) + .expect("test runtime config is valid") +} + +fn statement_session() -> SsoSessionInfo { + let mini_secret = MiniSecretKey::from_bytes(&[7; 32]).unwrap(); + let keypair = mini_secret.expand_to_keypair(ExpansionMode::Ed25519); + SsoSessionInfo { + ss_secret: keypair.secret.to_bytes(), + ss_public_key: keypair.public.to_bytes(), + enc_secret: [1; 32], + peer_enc_pubkey: [2; 65], + identity_account_id: [3; 32], + session_id_own: [4; 32], + session_id_peer: [5; 32], + request_channel: [6; 32], + response_channel: [7; 32], + peer_request_channel: [8; 32], + } +} + +fn sso_session() -> SsoSessionInfo { + let core_secret = SecretKey::from_slice(&[1; 32]).unwrap(); + let core_public = core_secret.public_key().to_encoded_point(false); + let bootstrap = PairingBootstrap { + deeplink: "polkadotapp://pair?handshake=00".to_string(), + topic: [0x11; 32], + statement_store_public_key: [0x22; 32], + statement_store_secret: [0x33; 64], + encryption_public_key: core_public.as_bytes().try_into().unwrap(), + encryption_secret_key: [1; 32], + }; + let peer_secret = SecretKey::from_slice(&[2; 32]).unwrap(); + let peer_sso_enc_pub_key = peer_secret + .public_key() + .to_encoded_point(false) + .as_bytes() + .try_into() + .unwrap(); + + establish_sso_session_info(&bootstrap, [0x55; 32], peer_sso_enc_pub_key).unwrap() +} + +#[wasm_bindgen_test] +fn product_account_and_entropy_vectors_match_dotli() { + let derived = derive_product_public_key(ROOT_PUBLIC_KEY, "myapp.dot", 0).unwrap(); + assert_eq!( + hex::encode(derived), + "281489e3dd1c4dbe88cd670a59edcc9c44d64f510d302bd527ec306f10292f08" + ); + assert_eq!( + product_public_key_to_address(derived), + "5CyFsdhwjXy7wWpDEM6isungQ3LfGnu9UXkt7paBQ6DYRxk1" + ); + + let entropy = derive_product_entropy(&entropy_secret(), "myapp.dot", b"product-key").unwrap(); + assert_eq!( + hex::encode(entropy), + "ab1887248c9de3cf4b8c5a255782796d3d35a98c8eb2d7df61a410db8b14da36" + ); +} + +#[wasm_bindgen_test] +fn pairing_deeplink_topic_and_scale_vectors_match_dotli() { + let config = runtime_config(); + let deeplink = build_pairing_deeplink("polkadotapp", SS_PUBLIC, ENC_PUBLIC, &config); + assert!(deeplink.starts_with("polkadotapp://pair?handshake=01")); + let encoded = hex::decode(deeplink.split("handshake=").nth(1).unwrap()).unwrap(); + let decoded = VersionedHandshakeProposal::decode(&mut &encoded[..]).unwrap(); + let VersionedHandshakeProposal::V2(proposal) = decoded; + assert_eq!(proposal.device.statement_account_id, SS_PUBLIC); + assert_eq!(proposal.device.encryption_public_key, ENC_PUBLIC); + assert!(proposal.metadata.contains(&pairing::v2::MetadataEntry( + pairing::v2::MetadataKey::HostName, + "Polkadot Web".to_string() + ))); + assert!(proposal.metadata.contains(&pairing::v2::MetadataEntry( + pairing::v2::MetadataKey::HostIcon, + "https://example.invalid/dotli.png".to_string() + ))); + assert_eq!( + hex::encode(bootstrap_topic(SS_PUBLIC, ENC_PUBLIC)), + "031c589833c39b1dfbe3c1304ced75fa7b0d841035db008e5b407bfadd2779a4" + ); + + let answer = VersionedHandshakeResponse::V2 { + encrypted_message: vec![0xde, 0xad], + public_key: ENC_PUBLIC, + }; + assert_eq!(decode_app_handshake_data(&answer.encode()).unwrap(), answer); +} + +#[wasm_bindgen_test] +fn p256_hkdf_aes_gcm_vectors_work_on_wasm() { + let core_secret = SecretKey::from_slice(&[1; 32]).unwrap(); + let wallet_ephemeral_secret = SecretKey::from_slice(&[2; 32]).unwrap(); + let wallet_ephemeral_public = wallet_ephemeral_secret.public_key().to_encoded_point(false); + + let shared_secret = diffie_hellman( + wallet_ephemeral_secret.to_nonzero_scalar(), + core_secret.public_key().as_affine(), + ); + let hkdf = Hkdf::::new(None, shared_secret.raw_secret_bytes()); + let mut aes_key = [0u8; 32]; + hkdf.expand(&[], &mut aes_key).unwrap(); + + let sensitive = pairing::v2::EncryptedResponse::Success(Box::new(pairing::v2::Success { + identity_account_id: [8; 32], + root_account_id: [7; 32], + identity_chat_private_key: [6; 32], + sso_enc_pub_key: ENC_PUBLIC, + device_enc_pub_key: ENC_PUBLIC, + root_entropy_source: [5; 32], + })); + let nonce = [9u8; AES_GCM_NONCE_LEN]; + let cipher = Aes256Gcm::new_from_slice(&aes_key).unwrap(); + let mut encrypted = nonce.to_vec(); + encrypted.extend( + cipher + .encrypt(Nonce::from_slice(&nonce), sensitive.encode().as_slice()) + .unwrap(), + ); + + assert_eq!( + decrypt_v2_handshake_response( + core_secret.to_bytes().into(), + wallet_ephemeral_public.as_bytes().try_into().unwrap(), + &encrypted, + ) + .unwrap(), + sensitive + ); +} + +#[wasm_bindgen_test] +fn session_crypto_and_statement_proof_vectors_work_on_wasm() { + let session = sso_session(); + let data = SsoStatementData::Request { + request_id: "req-1".to_string(), + data: vec![vec![0xde, 0xad]], + }; + let nonce = [9u8; AES_GCM_NONCE_LEN]; + let encrypted = encrypt_session_statement_data_with_nonce(&session, &data, nonce).unwrap(); + + assert_eq!(&encrypted[..AES_GCM_NONCE_LEN], nonce); + assert_eq!( + SsoStatementData::decode(&mut &data.encode()[..]).unwrap(), + data + ); + assert_eq!( + decrypt_session_statement_data(&session, &encrypted).unwrap(), + data + ); + + let statement_session = statement_session(); + let statement = + build_signed_session_request_statement(&statement_session, vec![0xde, 0xad], 42).unwrap(); + let verified = + decode_verified_statement_data(&statement, Some(statement_session.ss_public_key)).unwrap(); + + assert_eq!(verified.signer, statement_session.ss_public_key); + assert_eq!(verified.data, vec![0xde, 0xad]); +} diff --git a/rust/crates/truapi-server/tests/wire_result_shape.rs b/rust/crates/truapi-server/tests/wire_result_shape.rs new file mode 100644 index 00000000..16deb1f1 --- /dev/null +++ b/rust/crates/truapi-server/tests/wire_result_shape.rs @@ -0,0 +1,433 @@ +//! Result-wire-shape regression test. +//! +//! The TS host/client codec expects every request response to be a +//! `Versioned>` envelope on the wire (one leading version byte, +//! then one result discriminant byte, then the SCALE-encoded value). This test stands up a +//! `TrUApiCore::from_platform_with_config` with a platform whose `Features` +//! impl returns `Ok(supported = true)` and asserts: +//! +//! - A `system_feature_supported_request` produces a response whose +//! payload begins with `0x00` (V1), then `0x00` (Ok), followed by the encoded +//! `HostFeatureSupportedResponse`. +//! - A `local_storage_read_request` whose stub returns +//! `Err(HostLocalStorageReadError::Full)` produces a response whose +//! payload begins with `0x00` (V1), then `0x01` (Err), followed by the encoded +//! `HostLocalStorageReadError::Full`. +//! +//! Both halves prove the wire layout stays in lockstep with the TS +//! `S.indexedTaggedUnion({ V1: S.Result(ok, err) })` codec. + +use std::sync::Arc; + +use parity_scale_codec::{Decode, Encode}; + +use truapi::versioned::system::HostFeatureSupportedRequest; +use truapi::versioned::{Versioned, account, payment, statement_store}; +use truapi::{CallError, v01}; + +use truapi_server::core::TrUApiCore; +use truapi_server::frame::{Payload, ProtocolMessage, request_ids, subscription_ids}; + +mod common; +use common::{RecordingTransport, WireShapePlatform, test_runtime_config, test_spawner}; + +const PAYMENTS_NOT_IMPLEMENTED: &str = "Payments are not supported in dot.li"; + +fn dispatch(core: &TrUApiCore, frame: ProtocolMessage) -> ProtocolMessage { + let encoded = frame.encode(); + let response_bytes = futures::executor::block_on(core.receive_from_product(&encoded)) + .expect("dispatcher emitted a response frame"); + ProtocolMessage::decode(&mut &response_bytes[..]).expect("decode response") +} + +#[test] +fn feature_supported_ok_response_uses_ok_discriminant() { + let core = make_core(); + let request = HostFeatureSupportedRequest::V1(v01::HostFeatureSupportedRequest::Chain { + genesis_hash: vec![0u8; 32], + }); + let ids = request_ids("system_feature_supported").expect("known request method"); + let frame = ProtocolMessage { + request_id: "p:1".into(), + payload: Payload { + id: ids.request_id, + value: request.encode(), + }, + }; + let response = dispatch(&core, frame); + assert_eq!(response.request_id, "p:1"); + assert_eq!(response.payload.id, ids.response_id); + + // Wire payload: [V1 disc=0x00][Ok disc=0x00][encoded response body]. + let mut expected = vec![0x00u8, 0x00u8]; + v01::HostFeatureSupportedResponse { supported: true }.encode_to(&mut expected); + assert_eq!(response.payload.value, expected); + assert_eq!(response.payload.value.first(), Some(&0x00)); + assert_eq!(response.payload.value.get(1), Some(&0x00)); +} + +#[test] +fn local_storage_read_err_response_uses_err_discriminant() { + let core = make_core(); + let request = truapi::versioned::local_storage::HostLocalStorageReadRequest::V1( + v01::HostLocalStorageReadRequest { + key: "missing".to_string(), + }, + ); + let ids = request_ids("local_storage_read").expect("known request method"); + let frame = ProtocolMessage { + request_id: "p:2".into(), + payload: Payload { + id: ids.request_id, + value: request.encode(), + }, + }; + let response = dispatch(&core, frame); + assert_eq!(response.request_id, "p:2"); + assert_eq!(response.payload.id, ids.response_id); + + // Wire payload: + // [V1 disc=0x00][Err disc=0x01][CallError::Domain][V1 error][encoded error body]. + let mut expected = vec![0x00u8, 0x01u8]; + CallError::Domain( + truapi::versioned::local_storage::HostLocalStorageReadError::V1( + v01::HostLocalStorageReadError::Full, + ), + ) + .encode_to(&mut expected); + assert_eq!(response.payload.value, expected); + assert_eq!(response.payload.value.first(), Some(&0x00)); + assert_eq!(response.payload.value.get(1), Some(&0x01)); +} + +fn versioned_result_err_payload(error: E) -> Vec +where + E: Clone + Encode + Versioned, +{ + let mut expected = vec![version_index(error.version()), 0x01u8]; + CallError::Domain(error).encode_to(&mut expected); + expected +} + +fn versioned_interrupt_err_payload(error: E) -> Vec +where + E: Clone + Encode + Versioned, +{ + let mut expected = vec![version_index(error.version())]; + CallError::Domain(error).encode_to(&mut expected); + expected +} + +fn assert_request_returns_domain_error( + core: &TrUApiCore, + request_id: &str, + method: &str, + value: Vec, + error: E, +) where + E: Clone + Encode + Versioned, +{ + let ids = request_ids(method).expect("known request method"); + let response = dispatch( + core, + ProtocolMessage { + request_id: request_id.into(), + payload: Payload { + id: ids.request_id, + value, + }, + }, + ); + assert_eq!(response.request_id, request_id); + assert_eq!(response.payload.id, ids.response_id); + assert_eq!(response.payload.value, versioned_result_err_payload(error)); +} + +fn assert_subscription_start_interrupts_error( + core: &TrUApiCore, + request_id: &str, + method: &str, + value: Vec, + error: E, +) where + E: Clone + Encode + Versioned, +{ + let ids = subscription_ids(method).expect("known subscription method"); + let transport = Arc::new(RecordingTransport::default()); + futures::executor::block_on(core.dispatch( + ProtocolMessage { + request_id: request_id.into(), + payload: Payload { + id: ids.start_id, + value, + }, + }, + transport.clone(), + )); + + let sent = transport.sent.lock().unwrap(); + assert_eq!(sent.len(), 1); + assert_eq!(sent[0].request_id, request_id); + assert_eq!(sent[0].payload.id, ids.interrupt_id); + assert_eq!( + sent[0].payload.value, + versioned_interrupt_err_payload(error) + ); +} + +fn version_index(version: u8) -> u8 { + version.saturating_sub(1) +} + +#[test] +fn deferred_account_proof_returns_framework_unsupported() { + let core = make_core(); + let request = account::HostAccountCreateProofRequest::V1(v01::HostAccountCreateProofRequest { + product_account_id: v01::ProductAccountId { + dot_ns_identifier: "myapp.dot".to_string(), + derivation_index: 0, + }, + ring_location: v01::RingLocation { + genesis_hash: vec![0u8; 32], + ring_root_hash: vec![1u8; 32], + hints: None, + }, + context: Vec::new(), + }); + + let ids = request_ids("account_create_account_proof").expect("known request method"); + let response = dispatch( + &core, + ProtocolMessage { + request_id: "p:account-proof".into(), + payload: Payload { + id: ids.request_id, + value: request.encode(), + }, + }, + ); + assert_eq!(response.request_id, "p:account-proof"); + assert_eq!(response.payload.id, ids.response_id); + assert_eq!(response.payload.value, vec![0x00u8, 0x01u8, 0x02u8]); +} + +#[test] +fn deferred_payment_requests_return_dotli_not_implemented_errors() { + let core = make_core(); + let request = payment::HostPaymentRequest::V1(v01::HostPaymentRequest { + from: None, + amount: 1, + destination: [0u8; 32], + }); + + assert_request_returns_domain_error( + &core, + "p:payment", + "payment_request", + request.encode(), + payment::HostPaymentError::V1(v01::HostPaymentError::Unknown { + reason: PAYMENTS_NOT_IMPLEMENTED.to_string(), + }), + ); + + let top_up = payment::HostPaymentTopUpRequest::V1(v01::HostPaymentTopUpRequest { + into: None, + amount: 1, + source: v01::PaymentTopUpSource::ProductAccount { + derivation_index: 0, + }, + }); + assert_request_returns_domain_error( + &core, + "p:top-up", + "payment_top_up", + top_up.encode(), + payment::HostPaymentTopUpError::V1(v01::HostPaymentTopUpError::Unknown { + reason: PAYMENTS_NOT_IMPLEMENTED.to_string(), + }), + ); +} + +#[test] +fn deferred_payment_subscriptions_interrupt_dotli_not_implemented_errors() { + let core = make_core(); + let balance = + payment::HostPaymentBalanceSubscribeRequest::V1(v01::HostPaymentBalanceSubscribeRequest { + purse: None, + }); + assert_subscription_start_interrupts_error( + &core, + "p:balance", + "payment_balance_subscribe", + balance.encode(), + payment::HostPaymentBalanceSubscribeError::V1( + v01::HostPaymentBalanceSubscribeError::PermissionDenied, + ), + ); + + let status = + payment::HostPaymentStatusSubscribeRequest::V1(v01::HostPaymentStatusSubscribeRequest { + payment_id: "payment-id".to_string(), + }); + assert_subscription_start_interrupts_error( + &core, + "p:status", + "payment_status_subscribe", + status.encode(), + payment::HostPaymentStatusSubscribeError::V1( + v01::HostPaymentStatusSubscribeError::Unknown { + reason: PAYMENTS_NOT_IMPLEMENTED.to_string(), + }, + ), + ); +} + +#[test] +fn statement_store_subscribe_topic_limit_interrupts_with_typed_error() { + let core = make_core(); + let request = statement_store::RemoteStatementStoreSubscribeRequest::V1( + v01::RemoteStatementStoreSubscribeRequest::MatchAny(vec![[7u8; 32]; 129]), + ); + + assert_subscription_start_interrupts_error( + &core, + "p:ss-too-many", + "statement_store_subscribe", + request.encode(), + statement_store::RemoteStatementStoreSubscribeError::V1(v01::GenericError { + reason: "MatchAny has 129 topics, maximum is 128".to_string(), + }), + ); +} + +#[test] +fn malformed_result_subscription_start_interrupts_with_malformed_frame() { + let core = make_core(); + let method = "payment_balance_subscribe"; + let ids = subscription_ids(method).expect("known subscription method"); + let transport = Arc::new(RecordingTransport::default()); + + futures::executor::block_on(core.dispatch( + ProtocolMessage { + request_id: "p:malformed-sub".into(), + payload: Payload { + id: ids.start_id, + value: vec![0xff], + }, + }, + transport.clone(), + )); + + let sent = transport.sent.lock().unwrap(); + assert_eq!(sent.len(), 1); + assert_eq!(sent[0].request_id, "p:malformed-sub"); + assert_eq!(sent[0].payload.id, ids.interrupt_id); + assert_eq!(sent[0].payload.value.first(), Some(&0x00)); + + let mut payload = &sent[0].payload.value[1..]; + let error = CallError::::decode(&mut payload) + .expect("decode malformed interrupt error"); + assert!(payload.is_empty()); + match error { + CallError::MalformedFrame { reason } => assert!(!reason.is_empty()), + other => panic!("expected MalformedFrame interrupt, got {other:?}"), + } +} + +fn make_core() -> TrUApiCore { + let (host_config, product) = test_runtime_config(); + TrUApiCore::from_platform_with_config( + Arc::new(WireShapePlatform), + host_config, + product, + test_spawner(), + ) +} + +/// Untrusted product input that is not a decodable frame must be dropped +/// (return `None`), never panic. Exercises the decode-failure boundary in +/// `receive_from_product` that the happy-path tests above bypass. +#[test] +fn malformed_frames_are_dropped_without_panic() { + let core = make_core(); + + // Empty input and arbitrary garbage. + assert!(futures::executor::block_on(core.receive_from_product(&[])).is_none()); + assert!( + futures::executor::block_on(core.receive_from_product(&[0xff, 0xff, 0xff, 0xff])).is_none() + ); + + // A truncated SCALE string header (claims length but no body). + assert!( + futures::executor::block_on(core.receive_from_product(&[200u8 << 2, 0x61, 0x62])).is_none() + ); + + // A well-formed requestId envelope carrying an unknown wire discriminant. + let mut unknown_disc = Vec::new(); + "p:1".to_string().encode_to(&mut unknown_disc); + unknown_disc.push(0xFA); + unknown_disc.extend_from_slice(&[0u8; 4]); + assert!(futures::executor::block_on(core.receive_from_product(&unknown_disc)).is_none()); +} + +/// Drive a subscription through the encoded-frame boundary: `_start` yields +/// the initial `_receive`, then `_stop` tears it down so a later session +/// change produces no further frames. Covers the wire layer the in-crate +/// `subscription.rs` unit tests bypass. +#[test] +fn subscription_start_receive_stop_through_wire_boundary() { + use std::time::{Duration, Instant}; + use truapi_server::transport::Transport; + + let core = make_core(); + let transport = Arc::new(RecordingTransport::default()); + let dyn_transport: Arc = transport.clone(); + + let method = "account_connection_status_subscribe"; + let ids = subscription_ids(method).expect("known subscription method"); + let start = ProtocolMessage { + request_id: "p:1".into(), + payload: Payload { + id: ids.start_id, + value: Vec::new(), + }, + }; + futures::executor::block_on(core.dispatch(start, dyn_transport.clone())); + + // Wait for the initial `_receive` item (Disconnected). + let deadline = Instant::now() + Duration::from_secs(2); + while transport.sent.lock().unwrap().is_empty() { + assert!(Instant::now() < deadline, "no initial _receive frame"); + std::thread::sleep(Duration::from_millis(10)); + } + assert_eq!(transport.sent.lock().unwrap()[0].payload.id, ids.receive_id); + + // Stop the subscription, then push a session change. A live subscription + // would emit a Connected `_receive`; a stopped one must stay silent. + let stop = ProtocolMessage { + request_id: "p:1".into(), + payload: Payload { + id: ids.stop_id, + value: Vec::new(), + }, + }; + futures::executor::block_on(core.dispatch(stop, dyn_transport)); + std::thread::sleep(Duration::from_millis(50)); + + core.session_state() + .set_session(truapi_server::host_logic::session::SessionInfo { + public_key: [7u8; 32], + sso: None, + root_entropy_source: None, + identity_account_id: None, + lite_username: None, + full_username: None, + }); + std::thread::sleep(Duration::from_millis(50)); + + assert_eq!( + transport.sent.lock().unwrap().len(), + 1, + "stopped subscription must emit no further frames" + ); +} diff --git a/rust/crates/truapi/src/lib.rs b/rust/crates/truapi/src/lib.rs index b4baf520..4139b334 100644 --- a/rust/crates/truapi/src/lib.rs +++ b/rust/crates/truapi/src/lib.rs @@ -7,10 +7,14 @@ #![allow(async_fn_in_trait)] use core::convert::Infallible; +use core::fmt; +use core::future::Future; +use core::mem; use core::pin::Pin; -use core::task::{Context, Poll}; +use core::task::{Context, Poll, Waker}; +use core::time::Duration; use std::sync::Arc; -use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Mutex; use futures::Stream; use parity_scale_codec::{Decode, Encode}; @@ -23,14 +27,18 @@ pub mod latest { use crate::versioned::{self, Versioned}; pub use crate::v01::{ - AccountId, AllocatableResource, GenericError, HostSignPayloadData, NotificationId, - ProductAccountId, RawPayload, RemotePermission, ThemeVariant, + AccountId, AllocatableResource, AllocationOutcome, GenericError, HostSignPayloadData, + NotificationId, OperationStartedResult, ProductAccountId, RawPayload, RemotePermission, + RuntimeApi, RuntimeSpec, RuntimeType, StorageQueryItem, StorageQueryType, + StorageResultItem, ThemeVariant, }; pub type LatestOf = ::Latest; pub type HostAccountGetAliasResponse = LatestOf; + pub type HostCreateTransactionResponse = + LatestOf; pub type HostDevicePermissionRequest = LatestOf; pub type HostDevicePermissionResponse = @@ -45,9 +53,14 @@ pub mod latest { LatestOf; pub type HostPushNotificationResponse = LatestOf; + pub type HostRequestLoginError = LatestOf; + pub type HostRequestLoginResponse = LatestOf; pub type HostRequestResourceAllocationRequest = LatestOf; + pub type HostRequestResourceAllocationResponse = + LatestOf; pub type HostSignPayloadRequest = LatestOf; + pub type HostSignPayloadResponse = LatestOf; pub type HostSignPayloadWithLegacyAccountRequest = LatestOf; pub type HostSignRawRequest = LatestOf; @@ -57,6 +70,13 @@ pub mod latest { LatestOf; pub type PreimageSubmitError = LatestOf; pub type ProductAccountTxPayload = LatestOf; + pub type RemoteChainHeadFollowItem = LatestOf; + pub type RemoteChainHeadFollowRequest = + LatestOf; + pub type RemoteChainHeadStorageRequest = + LatestOf; + pub type RemoteChainHeadStorageResponse = + LatestOf; pub type RemotePermissionRequest = LatestOf; pub type RemotePermissionResponse = LatestOf; } @@ -96,11 +116,62 @@ pub type FrameworkOnlyError = CallError; /// Cooperative cancellation token exposed to handlers. /// /// Current one-shot request frames have no cancel control message, so request -/// tokens only fire when a future runtime explicitly cancels them. Subscription -/// runtimes can cancel this token when the peer sends `_stop` or disconnects. -#[derive(Debug, Clone, Default)] +/// tokens fire when a runtime explicitly cancels them or attaches a timeout. +/// Subscription runtimes can cancel this token when the peer sends `_stop` or +/// disconnects. pub struct CancellationToken { - cancelled: Arc, + inner: Arc, +} + +#[derive(Default)] +struct CancellationInner { + state: Mutex, +} + +#[derive(Default)] +struct CancellationState { + reason: Option, + next_id: u64, + wakers: Vec<(u64, Waker)>, +} + +/// Cause attached to a cancelled call. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CancellationReason { + /// The caller or runtime explicitly cancelled the call. + Cancelled, + /// The call exceeded the configured timeout. + TimedOut { timeout: Duration }, +} + +/// Future resolved when a [`CancellationToken`] is cancelled. +pub struct CancellationFuture { + inner: Arc, + id: Option, +} + +impl fmt::Debug for CancellationToken { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("CancellationToken") + .field("reason", &self.reason()) + .finish_non_exhaustive() + } +} + +impl Clone for CancellationToken { + fn clone(&self) -> Self { + Self { + inner: self.inner.clone(), + } + } +} + +impl Default for CancellationToken { + fn default() -> Self { + Self { + inner: Arc::new(CancellationInner::default()), + } + } } impl CancellationToken { @@ -111,19 +182,98 @@ impl CancellationToken { /// Mark the token as cancelled. pub fn cancel(&self) { - self.cancelled.store(true, Ordering::SeqCst); + self.cancel_with_reason(CancellationReason::Cancelled); + } + + /// Mark the token as cancelled with an explicit `reason`. + pub fn cancel_with_reason(&self, reason: CancellationReason) { + let wakers = { + let mut state = self.inner.state.lock().expect("cancel state poisoned"); + if state.reason.is_some() { + return; + } + state.reason = Some(reason); + mem::take(&mut state.wakers) + }; + for (_, waker) in wakers { + waker.wake(); + } + } + + /// Returns the cancellation reason, if cancellation has been requested. + pub fn reason(&self) -> Option { + self.inner + .state + .lock() + .expect("cancel state poisoned") + .reason + .clone() } /// Returns whether cancellation has been requested. pub fn is_cancelled(&self) -> bool { - self.cancelled.load(Ordering::SeqCst) + self.reason().is_some() + } + + /// Future resolved with the cancellation reason when cancellation is requested. + pub fn cancelled(&self) -> CancellationFuture { + CancellationFuture { + inner: self.inner.clone(), + id: None, + } + } +} + +impl Future for CancellationFuture { + type Output = CancellationReason; + + fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { + let this = self.get_mut(); + let mut state = this.inner.state.lock().expect("cancel state poisoned"); + if let Some(reason) = state.reason.clone() { + this.id = None; + return Poll::Ready(reason); + } + + if let Some(id) = this.id + && let Some((_, waker)) = state + .wakers + .iter_mut() + .find(|(waiter_id, _)| *waiter_id == id) + { + if !waker.will_wake(cx.waker()) { + *waker = cx.waker().clone(); + } + return Poll::Pending; + } + + state.next_id = state.next_id.wrapping_add(1); + let id = state.next_id; + state.wakers.push((id, cx.waker().clone())); + this.id = Some(id); + Poll::Pending + } +} + +impl Drop for CancellationFuture { + fn drop(&mut self) { + let Some(id) = self.id.take() else { + return; + }; + let mut state = self.inner.state.lock().expect("cancel state poisoned"); + if state.reason.is_some() { + return; + } + state.wakers.retain(|(waiter_id, _)| *waiter_id != id); } } /// Ambient context passed to every trait method. +#[derive(Clone)] pub struct CallContext { request_id: RequestId, cancel: CancellationToken, + timeout: Option, } impl CallContext { @@ -137,12 +287,22 @@ impl CallContext { Self { request_id, cancel: CancellationToken::new(), + timeout: None, } } /// Construct a context from explicit `request_id` and `cancel` parts. pub fn with_parts(request_id: RequestId, cancel: CancellationToken) -> Self { - Self { request_id, cancel } + Self { + request_id, + cancel, + timeout: None, + } + } + + /// Attach a timeout to this call. + pub fn set_timeout(&mut self, timeout: Duration) { + self.timeout = Some(timeout); } /// Return the request id this context is associated with. @@ -154,6 +314,11 @@ impl CallContext { pub fn cancel(&self) -> &CancellationToken { &self.cancel } + + /// Return the timeout attached to this call, if any. + pub fn timeout(&self) -> Option { + self.timeout + } } impl Default for CallContext { @@ -192,3 +357,35 @@ impl Subscription { Self::new(Box::pin(futures::stream::empty())) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn call_context_timeout_can_be_set_and_replaced() { + let default = Duration::from_secs(180); + let explicit = Duration::from_millis(25); + + let mut cx = CallContext::with_request_id("request-1".to_string()); + assert_eq!(cx.timeout(), None); + cx.set_timeout(default); + assert_eq!(cx.timeout(), Some(default)); + + cx.set_timeout(explicit); + assert_eq!(cx.timeout(), Some(explicit)); + } + + #[test] + fn cancellation_token_clones_share_cancellation() { + let token = CancellationToken::new(); + let cloned = token.clone(); + let wait = cloned.cancelled(); + + token.cancel(); + + let reason = futures::executor::block_on(wait); + assert_eq!(reason, CancellationReason::Cancelled); + assert!(cloned.is_cancelled()); + } +} diff --git a/scripts/codegen.sh b/scripts/codegen.sh index 86c17f78..da696fd6 100755 --- a/scripts/codegen.sh +++ b/scripts/codegen.sh @@ -11,6 +11,7 @@ # --platform-input target/doc/truapi_platform.json # --platform-ts-output js/packages/truapi-host-wasm/src/generated # --platform-wasm-adapter-output js/packages/truapi-host-wasm/src/generated +# --platform-rust-output rust/crates/truapi-server/src/wasm # --codec-version 1 # # The client surface defaults to the latest wire version any versioned @@ -34,6 +35,7 @@ cargo run -p truapi-codegen -- \ --platform-input target/doc/truapi_platform.json \ --platform-ts-output js/packages/truapi-host-wasm/src/generated \ --platform-wasm-adapter-output js/packages/truapi-host-wasm/src/generated \ + --platform-rust-output rust/crates/truapi-server/src/wasm \ --explorer-output js/packages/truapi/src/explorer \ --codec-version 1 @@ -70,3 +72,4 @@ echo "Generated playground metadata at js/packages/truapi/src/playground/codegen echo "Generated client examples at playground/test/generated/examples/" echo "Generated Rust dispatcher at rust/crates/truapi-server/src/generated/" echo "Generated host-callbacks WASM adapter at js/packages/truapi-host-wasm/src/generated/" +echo "Generated Rust WASM bridge at rust/crates/truapi-server/src/wasm/generated_bridge.rs"