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/PULL_REQUEST_TEMPLATE/release.md b/.github/PULL_REQUEST_TEMPLATE/release.md index a2f7c0c9..f213a957 100644 --- a/.github/PULL_REQUEST_TEMPLATE/release.md +++ b/.github/PULL_REQUEST_TEMPLATE/release.md @@ -2,7 +2,7 @@ > [!IMPORTANT] > The PR title must start with `release:` for the publish workflow to fire. -> Example: `release: @parity/truapi 0.1.1`. +> Example: `release: @parity/truapi 0.1.1` or `release: @parity/truapi-host 0.1.1`. > Don't rewrite the squash commit subject in the merge dialog — the > `release:` prefix has to land on `main`. @@ -12,10 +12,9 @@ ### Checklist -- [ ] Ran `npm run changeset` and selected the bump type (patch / minor / major) +- [ ] Ran `npm run changeset` and selected the package + bump type (patch / minor / major) - [ ] Ran `npm run version-packages` to consume the changeset - [ ] `js/packages/truapi/package.json` version is bumped - [ ] `js/packages/truapi/CHANGELOG.md` has the new entry - [ ] `rust/crates/truapi/Cargo.toml` version matches `js/packages/truapi/package.json` - [ ] No leftover files under `.changeset/` (other than `config.json`) - diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e3afa820..64c251dc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -94,6 +94,15 @@ jobs: - name: Run codegen run: ./scripts/codegen.sh + - name: Check generated Rust output is committed + run: | + git diff --exit-code -- \ + rust/crates/truapi-server/src/generated \ + rust/crates/truapi-server/src/wasm/generated_bridge.rs + + - name: Check Rust/TS wire table parity + run: TRUAPI_REQUIRE_GENERATED_TS=1 cargo test -p truapi-server --test wire_table_ts_parity + - name: Upload codegen output uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: @@ -105,10 +114,11 @@ jobs: js/packages/truapi/src/playground/codegen js/packages/truapi/src/explorer/codegen js/packages/truapi/src/explorer/versions.ts + js/packages/truapi-host/src/generated playground/test/generated ts-client: - name: '@parity/truapi' + name: "@parity/truapi" runs-on: ubuntu-latest needs: codegen env: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 835f03fc..2af249ba 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -6,7 +6,7 @@ name: Release # guarded by conclusion == success and a release: commit subject, and checks # out the pinned head_sha with persist-credentials: false. No untrusted PR code # ever runs in this privileged context. -on: # zizmor: ignore[dangerous-triggers] +on: # zizmor: ignore[dangerous-triggers] workflow_run: workflows: ["CI"] types: [completed] @@ -29,6 +29,8 @@ jobs: runs-on: ubuntu-latest permissions: contents: write + id-token: write + attestations: write steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: @@ -36,19 +38,45 @@ jobs: ref: ${{ github.event.workflow_run.head_sha }} persist-credentials: false - - name: Resolve target version + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: "22" + registry-url: "https://registry.npmjs.org" + + - name: Resolve target packages id: version run: | - version=$(jq -r '.version' js/packages/truapi/package.json) - tag="@parity/truapi@${version}" - if git rev-parse --verify --quiet "refs/tags/${tag}" >/dev/null; then - echo "Tag ${tag} already exists; nothing to publish." - echo "proceed=false" >> "$GITHUB_OUTPUT" - else - echo "Publishing ${tag}." + set -euo pipefail + packages=( + "@parity/truapi js/packages/truapi" + "@parity/truapi-host js/packages/truapi-host" + ) + pending="$(mktemp)" + for entry in "${packages[@]}"; do + name="${entry%% *}" + path="${entry#* }" + version="$(jq -r '.version' "${path}/package.json")" + tag="${name}@${version}" + if npm_status="$(npm view "${tag}" version --registry=https://registry.npmjs.org 2>&1)"; then + echo "Package ${tag} already exists on npm; skipping ${name}." + elif grep -Eq 'E404|404 Not Found' <<< "${npm_status}"; then + echo "Publishing ${tag}." + echo "${name}|${path}|${version}|${tag}" >> "${pending}" + else + echo "${npm_status}" >&2 + exit 1 + fi + done + if [ -s "${pending}" ]; then echo "proceed=true" >> "$GITHUB_OUTPUT" - echo "version=${version}" >> "$GITHUB_OUTPUT" - echo "tag=${tag}" >> "$GITHUB_OUTPUT" + { + echo "packages<> "$GITHUB_OUTPUT" + else + echo "No unpublished package tags found." + echo "proceed=false" >> "$GITHUB_OUTPUT" fi - if: steps.version.outputs.proceed == 'true' @@ -64,35 +92,36 @@ jobs: - if: steps.version.outputs.proceed == 'true' uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 - - if: steps.version.outputs.proceed == 'true' - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 - with: - node-version: "22" - registry-url: "https://registry.npmjs.org" - - name: Install dependencies if: steps.version.outputs.proceed == 'true' run: npm ci - - name: Build @parity/truapi - if: steps.version.outputs.proceed == 'true' - run: ./scripts/codegen.sh + - name: Install wasm-pack + if: steps.version.outputs.proceed == 'true' && contains(steps.version.outputs.packages, '@parity/truapi-host|') + run: cargo install wasm-pack --version 0.14.0 --locked - - name: Tag release + - name: Build packages if: steps.version.outputs.proceed == 'true' - 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 }}" + set -euo pipefail + ./scripts/codegen.sh + if grep -q '^@parity/truapi-host|' <<< "${STEPS_VERSION_OUTPUTS_PACKAGES}"; then + npm run build --prefix js/packages/truapi-host + npm run build:wasm --prefix js/packages/truapi-host + fi + env: + STEPS_VERSION_OUTPUTS_PACKAGES: ${{ steps.version.outputs.packages }} - name: Pack package if: steps.version.outputs.proceed == 'true' run: | + set -euo pipefail mkdir -p artifacts - (cd js/packages/truapi && npm pack --pack-destination "$GITHUB_WORKSPACE/artifacts/") + while IFS='|' read -r name path version tag; do + (cd "${path}" && npm pack --pack-destination "$GITHUB_WORKSPACE/artifacts/") + done <<< "${STEPS_VERSION_OUTPUTS_PACKAGES}" + env: + STEPS_VERSION_OUTPUTS_PACKAGES: ${{ steps.version.outputs.packages }} - name: Upload package artifacts if: steps.version.outputs.proceed == 'true' @@ -101,6 +130,12 @@ jobs: name: package path: artifacts/*.tgz + - name: Attest package artifacts + if: steps.version.outputs.proceed == 'true' + uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1 + with: + subject-path: artifacts/*.tgz + - name: Publish via npm_publish_automation if: steps.version.outputs.proceed == 'true' uses: octokit/request-action@b91aabaa861c777dcdb14e2387e30eddf04619ae # v3.0.0 @@ -111,14 +146,45 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.NPM_PUBLISH_AUTOMATION_TOKEN }} + - name: Create release tags + if: steps.version.outputs.proceed == 'true' + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RELEASE_SHA: ${{ github.event.workflow_run.head_sha }} + STEPS_VERSION_OUTPUTS_PACKAGES: ${{ steps.version.outputs.packages }} + run: | + set -euo pipefail + auth_header="AUTHORIZATION: bearer ${GITHUB_TOKEN}" + push_refs=() + while IFS='|' read -r name path version tag; do + if git -c http.extraheader="${auth_header}" ls-remote --exit-code --tags origin "refs/tags/${tag}" >/dev/null 2>&1; then + echo "Tag ${tag} already exists; skipping create." + continue + fi + git tag --force "${tag}" "${RELEASE_SHA}" + push_refs+=("refs/tags/${tag}:refs/tags/${tag}") + done <<< "${STEPS_VERSION_OUTPUTS_PACKAGES}" + + if [ "${#push_refs[@]}" -gt 0 ]; then + git -c http.extraheader="${auth_header}" push origin "${push_refs[@]}" + fi + - name: Create GitHub Release if: steps.version.outputs.proceed == 'true' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - STEPS_VERSION_OUTPUTS_TAG: ${{ steps.version.outputs.tag }} - STEPS_VERSION_OUTPUTS_VERSION: ${{ steps.version.outputs.version }} + STEPS_VERSION_OUTPUTS_PACKAGES: ${{ steps.version.outputs.packages }} run: | - gh release create "${STEPS_VERSION_OUTPUTS_TAG}" \ - --title "@parity/truapi ${STEPS_VERSION_OUTPUTS_VERSION}" \ - --generate-notes \ - artifacts/*.tgz + set -euo pipefail + while IFS='|' read -r name path version tag; do + tarball_name="${name#@}" + tarball_name="${tarball_name//\//-}-${version}.tgz" + if gh release view "${tag}" >/dev/null 2>&1; then + gh release upload "${tag}" "artifacts/${tarball_name}" --clobber + else + gh release create "${tag}" \ + --title "${name} ${version}" \ + --generate-notes \ + "artifacts/${tarball_name}" + fi + done <<< "${STEPS_VERSION_OUTPUTS_PACKAGES}" diff --git a/CLAUDE.md b/CLAUDE.md index 0116c828..d0df0ce5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,17 +8,39 @@ 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/ @parity/truapi-host: 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. +- Outside the canonical `truapi` crate and its version-conversion impls, use + structs from `truapi::latest` for concrete protocol payload/error types. + Runtime crates should take envelopes from `truapi::versioned::*` and unwrap + them into latest payloads instead of spelling `truapi::v01::*` directly. +- `truapi-server` WASM artifacts live under + `js/packages/truapi-host/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 +48,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 +154,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`, 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` 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.toml b/Cargo.toml index 042aff82..1fbe8141 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,3 +5,11 @@ members = ["rust/crates/*"] [workspace.package] edition = "2024" license = "MIT" + +[profile.release] +opt-level = "z" +lto = "fat" +codegen-units = 1 +panic = "abort" +strip = "symbols" +debug = false diff --git a/Makefile b/Makefile index f067ddbf..4aeb0764 100644 --- a/Makefile +++ b/Makefile @@ -3,60 +3,141 @@ # 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 clean 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 +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 +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/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 && npm test check: ## Full verification suite (build, fmt, clippy, test, TS tests, playground build + lint). cargo build --workspace cargo check --target wasm32-unknown-unknown -p truapi-server cargo +nightly fmt --check cargo clippy --workspace --all-targets --all-features -- -D warnings - cargo test --workspace + cargo test --workspace --all-features --all-targets cd $(TRUAPI_PKG) && npm run build && npm test + cd $(JS_PACKAGES)/truapi-host && npm install --no-fund --no-audit && npm test cd $(PLAYGROUND) && yarn build && yarn lint +clean: ## Remove local build/test artifacts without deleting dependencies. + cargo clean + rm -rf \ + $(TRUAPI_PKG)/dist \ + $(TRUAPI_PKG)/tsconfig.tsbuildinfo \ + $(HOST_WASM_PKG)/dist \ + $(HOST_WASM_PKG)/tsconfig.tsbuildinfo \ + $(PLAYGROUND)/.next \ + $(PLAYGROUND)/out \ + $(PLAYGROUND)/test-results \ + $(PLAYGROUND)/tsconfig.tsbuildinfo \ + $(PLAYGROUND)/tests/tsconfig.tsbuildinfo \ + $(DOTLI)/.turbo \ + $(DOTLI)/apps/host/dist \ + $(DOTLI)/apps/protocol/dist \ + $(DOTLI)/apps/sandbox/dist \ + $(DOTLI)/test-results + playground: ## Refresh the playground's @parity/truapi snapshot and rebuild. cd $(TRUAPI_PKG) && npm run build 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 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 dist. Run: npm run build --prefix $(HOST_WASM_PKG)"; exit 1) + @test -f "$(HOST_WASM_WEB)" || (echo "Missing @parity/truapi-host web WASM glue. Run: make wasm"; exit 1) + @test -e "$(DOTLI_HOST_WASM_LINK)/package.json" || (echo "dotli cannot resolve @parity/truapi-host. Run top-level: make dev"; exit 1) + cd $(DOTLI_UI) && bun -e 'await import("@parity/truapi-host"); await import("@parity/truapi-host/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 $(PLAYGROUND) && bun tests/e2e/dotli-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..a5c38f00 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/ @parity/truapi-host: 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`](js/packages/truapi-host), +a single package with tree-shakeable subpath entries: + +- `@parity/truapi-host` (the `.` entry) exposes shared host runtime types and generated callback contracts. +- `@parity/truapi-host/web` wires the WASM provider into a browser host: the iframe + MessageChannel handshake (`createIframeHost`) plus `createWebWorkerProvider`. +- `@parity/truapi-host/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 +make test # Rust + TypeScript client + @parity/truapi-host 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/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/RELEASE_PROCESS.md b/docs/RELEASE_PROCESS.md index adda268c..bc52db8b 100644 --- a/docs/RELEASE_PROCESS.md +++ b/docs/RELEASE_PROCESS.md @@ -1,9 +1,9 @@ -# Releasing `@parity/truapi` +# Releasing npm packages -The `@parity/truapi` npm package is published by +The `@parity/truapi` and `@parity/truapi-host` npm packages are published by [`paritytech/npm_publish_automation`](https://github.com/paritytech/npm_publish_automation). We never run `npm publish` locally or from a personal account; the -`Release` workflow in `.github/workflows/release.yml` packs the package +`Release` workflow in `.github/workflows/release.yml` packs the packages and dispatches the automation. Releases happen via a dedicated **release PR**. Nothing publishes @@ -31,11 +31,13 @@ npm run version-packages # consumes the changeset, bumps package.json + writ ``` The first command writes a markdown file under `.changeset/`; the second -consumes it, bumps `js/packages/truapi/package.json`, appends an entry to -`js/packages/truapi/CHANGELOG.md`, deletes the changeset file, and then -runs `scripts/sync-cargo-version.mjs` to bump -`rust/crates/truapi/Cargo.toml` to the same version. All three files -should appear in the resulting diff. +consumes it, bumps the selected package `package.json`, appends the package +`CHANGELOG.md`, deletes the changeset file, and then runs +`scripts/sync-cargo-version.mjs` to keep `rust/crates/truapi/Cargo.toml` +aligned with `js/packages/truapi/package.json`. A protocol release should +therefore include the `@parity/truapi` package, its changelog, and the Cargo +version. A host-runtime-only release can bump `@parity/truapi-host` without +changing the Rust crate version. ### 3. Open a release PR @@ -49,6 +51,7 @@ The PR title must start with `release:`. Convention: ``` release: @parity/truapi 0.1.1 +release: @parity/truapi-host 0.1.1 ``` ### 4. Get the PR reviewed and merged @@ -66,11 +69,14 @@ say); the tag-already-exists guard makes re-runs safe. On merge, CI runs as usual. When CI passes, the `Release` workflow: 1. Confirms the commit subject starts with `release:`. -2. Reads the new version from `js/packages/truapi/package.json` and - checks no `@parity/truapi@` tag exists yet (re-runs are - idempotent — they skip). -3. Builds the package, creates and pushes the tag, packs the tarball, - and dispatches to `npm_publish_automation`. +2. Reads package versions from `js/packages/truapi/package.json` and + `js/packages/truapi-host/package.json`. +3. Checks for `@parity/truapi@` and + `@parity/truapi-host@` tags. Packages whose tag already exists + are skipped, so re-runs are idempotent. +4. Builds generated sources and the host WASM bundle, creates and pushes tags + for unpublished packages, packs their tarballs, and dispatches to + `npm_publish_automation`. You can watch the dispatched run under [`paritytech/npm_publish_automation` Actions](https://github.com/paritytech/npm_publish_automation/actions). @@ -79,7 +85,7 @@ You can watch the dispatched run under - A feature PR that accidentally bumps `package.json` will **not** trigger a publish — only `release:` PRs do. -- A `release:` PR that forgets to bump the version will be skipped at +- A `release:` PR that forgets to bump package versions will be skipped at the tag-already-exists check, not silently re-publish over an existing version. - A `release:` PR with mismatched `js/packages/truapi/package.json` and diff --git a/docs/local-e2e-testing.md b/docs/local-e2e-testing.md index f646e2db..7ffde321 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 +`playground/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/docs/rfcs/0010-allowance.md b/docs/rfcs/0010-allowance.md index b8cd32b9..fe31710d 100644 --- a/docs/rfcs/0010-allowance.md +++ b/docs/rfcs/0010-allowance.md @@ -87,7 +87,7 @@ enum AllocatableResource { /// Allocate slot in SSS slot table StatementStoreAllowance, /// Allocate slot in Bulletin slot table - BulletInAllowance, + BulletinAllowance, /// Pre-claim PGAS into product account at `dest` to cover AH fees / storage deposits SmartContractAllowance { dest: DerivationIndex }, /// Grant auto-signing from product's own accounts. @@ -134,7 +134,7 @@ struct ResourceAllocationRequest { enum ApAllocatableResource { StatementStoreAllowance, - BulletInAllowance, + BulletinAllowance, SmartContractAllowance { dest: DerivationIndex }, AutoSigning, } @@ -162,7 +162,7 @@ enum ApAllocationOutcome { enum ApAllocatedResource { StatementStoreAllowance { slot_account_key: PrivateKey }, - BulletInAllowance { slot_account_key: PrivateKey }, + BulletinAllowance { slot_account_key: PrivateKey }, SmartContractAllowance, AutoSigning { /// Secret component of the soft-derivation path. @@ -252,7 +252,7 @@ sequenceDiagram participant B as Bulletin Chain P->>H: preimage_submit(chunk) - H->>A: request_resource_allocation(ProductId, [BulletInAllowance]) + H->>A: request_resource_allocation(ProductId, [BulletinAllowance]) A->>U: show authorization UI (single resource) U-->>A: approve A->>A: derive //allowance//bulletin//{productId} diff --git a/hosts/dotli b/hosts/dotli index 80bedceb..4812841e 160000 --- a/hosts/dotli +++ b/hosts/dotli @@ -1 +1 @@ -Subproject commit 80bedceb98bd6f305f5bf134c0225c203752ecac +Subproject commit 4812841ea9d727e96fa241b3340c0296558e6a8a diff --git a/js/packages/truapi-host/README.md b/js/packages/truapi-host/README.md index 5eae403c..44e0cd0f 100644 --- a/js/packages/truapi-host/README.md +++ b/js/packages/truapi-host/README.md @@ -19,8 +19,10 @@ The package exposes tree-shakeable subpath exports — import only what your env 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`). +chain calls fail with the core's standard unavailable error. Release builds use +the workspace size-optimized Rust profile plus `wasm-opt -Oz`, validate that +debug/name/producers custom sections were stripped, and emit `.wasm.gz` and +`.wasm.br` sidecars for hosts that serve precompressed assets. 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): diff --git a/js/packages/truapi-host/scripts/build-wasm.mjs b/js/packages/truapi-host/scripts/build-wasm.mjs index 97583e4a..20794ea7 100644 --- a/js/packages/truapi-host/scripts/build-wasm.mjs +++ b/js/packages/truapi-host/scripts/build-wasm.mjs @@ -3,17 +3,29 @@ // `dist/wasm/web/`. wasm-pack is required. import { execFile } from "node:child_process"; -import { rm } from "node:fs/promises"; +import { readFile, rm, writeFile } from "node:fs/promises"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { promisify } from "node:util"; +import { + brotliCompress, + brotliDecompress, + constants as zlibConstants, + gzip, + gunzip, +} from "node:zlib"; const execFileAsync = promisify(execFile); +const brotliCompressAsync = promisify(brotliCompress); +const brotliDecompressAsync = promisify(brotliDecompress); +const gzipAsync = promisify(gzip); +const gunzipAsync = promisify(gunzip); 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"; +const wasmFileName = "truapi_server_bg.wasm"; function args(target, outDir) { const command = [ @@ -38,6 +50,112 @@ function args(target, outDir) { return command; } +function formatBytes(bytes) { + if (bytes < 1024) return `${bytes} B`; + const kib = bytes / 1024; + if (kib < 1024) return `${kib.toFixed(1)} KiB`; + return `${(kib / 1024).toFixed(2)} MiB`; +} + +function readVarUint(bytes, cursor) { + let result = 0; + let shift = 0; + let position = cursor; + while (position < bytes.length) { + const byte = bytes[position]; + result += (byte & 0x7f) * 2 ** shift; + position += 1; + if ((byte & 0x80) === 0) { + return [result, position]; + } + shift += 7; + } + throw new Error("unterminated wasm varuint"); +} + +function readCustomSectionNames(bytes) { + if ( + bytes.length < 8 || + bytes[0] !== 0x00 || + bytes[1] !== 0x61 || + bytes[2] !== 0x73 || + bytes[3] !== 0x6d + ) { + throw new Error("generated file is not a wasm module"); + } + + const names = []; + let offset = 8; + while (offset < bytes.length) { + const sectionId = bytes[offset]; + offset += 1; + const [sectionSize, payloadStart] = readVarUint(bytes, offset); + const payloadEnd = payloadStart + sectionSize; + if (payloadEnd > bytes.length) { + throw new Error("wasm section extends past end of file"); + } + if (sectionId === 0) { + const [nameLength, nameStart] = readVarUint(bytes, payloadStart); + const nameEnd = nameStart + nameLength; + if (nameEnd > payloadEnd) { + throw new Error("wasm custom section name extends past section end"); + } + names.push( + Buffer.from(bytes.subarray(nameStart, nameEnd)).toString("utf8"), + ); + } + offset = payloadEnd; + } + return names; +} + +async function validateReleaseWasm(wasmPath) { + if (wasmProfile !== "release") return; + + const wasm = await readFile(wasmPath); + const customSections = readCustomSectionNames(wasm); + const forbidden = customSections.filter( + (name) => + name === "name" || name === "producers" || name.startsWith(".debug"), + ); + if (forbidden.length > 0) { + throw new Error( + `release wasm retained debug/metadata custom sections: ${forbidden.join(", ")}`, + ); + } +} + +async function writeCompressedSidecars(wasmPath) { + if (wasmProfile !== "release") return; + + const wasm = await readFile(wasmPath); + const gzipBytes = await gzipAsync(wasm, { level: 9 }); + const brotliBytes = await brotliCompressAsync(wasm, { + params: { + [zlibConstants.BROTLI_PARAM_QUALITY]: 11, + }, + }); + + await writeFile(`${wasmPath}.gz`, gzipBytes); + await writeFile(`${wasmPath}.br`, brotliBytes); + + const [gzipRoundTrip, brotliRoundTrip] = await Promise.all([ + gunzipAsync(gzipBytes), + brotliDecompressAsync(brotliBytes), + ]); + if (!gzipRoundTrip.equals(wasm) || !brotliRoundTrip.equals(wasm)) { + throw new Error("compressed wasm sidecar round-trip validation failed"); + } + + process.stdout.write( + [ + `wasm size: ${formatBytes(wasm.length)}`, + `gzip: ${formatBytes(gzipBytes.length)}`, + `brotli: ${formatBytes(brotliBytes.length)}`, + ].join(" | ") + "\n", + ); +} + async function build(target, subdir) { const outDir = resolve(pkgRoot, "dist/wasm", subdir); process.stdout.write( @@ -58,6 +176,13 @@ async function build(target, subdir) { // wasm-pack writes a nested `.gitignore: *`; the repo-level ignore already // owns generated WASM outputs. await rm(resolve(outDir, ".gitignore"), { force: true }); + const wasmPath = resolve(outDir, wasmFileName); + await Promise.all([ + rm(`${wasmPath}.br`, { force: true }), + rm(`${wasmPath}.gz`, { force: true }), + ]); + await validateReleaseWasm(wasmPath); + await writeCompressedSidecars(wasmPath); } await build("web", "web"); diff --git a/js/packages/truapi-host/src/host-callbacks-adapter.test.ts b/js/packages/truapi-host/src/host-callbacks-adapter.test.ts index 1acaac6f..aa4fd591 100644 --- a/js/packages/truapi-host/src/host-callbacks-adapter.test.ts +++ b/js/packages/truapi-host/src/host-callbacks-adapter.test.ts @@ -386,22 +386,14 @@ describe("createWasmRawCallbacks", () => { ); const seen: ThemeVariant[] = []; const errors: GenericError[] = []; - let resolveError!: (error: GenericError) => void; - const errorSeen = new Promise((resolve) => { - resolveError = resolve; - }); const dispose = raw.subscribeTheme?.( (theme) => seen.push(ThemeVariant.dec(theme!)), - (error) => { - errors.push(error); - resolveError(error); - }, + (error) => errors.push(error), ); await settle(); expect(seen).toEqual(["Dark"]); - expect(await errorSeen).toEqual({ reason: "theme stream failed" }); expect(errors).toEqual([{ reason: "theme stream failed" }]); dispose?.(); }); @@ -421,22 +413,14 @@ describe("createWasmRawCallbacks", () => { ); const seen: ThemeVariant[] = []; const errors: GenericError[] = []; - let resolveError!: (error: GenericError) => void; - const errorSeen = new Promise((resolve) => { - resolveError = resolve; - }); const dispose = raw.subscribeTheme?.( (theme) => seen.push(ThemeVariant.dec(theme!)), - (error) => { - errors.push(error); - resolveError(error); - }, + (error) => errors.push(error), ); await settle(); expect(seen).toEqual(["Dark"]); - expect(await errorSeen).toEqual({ reason: "theme iterator failed" }); expect(errors).toEqual([{ reason: "theme iterator failed" }]); dispose?.(); }); diff --git a/js/packages/truapi-host/src/web/worker-provider.test.ts b/js/packages/truapi-host/src/web/worker-provider.test.ts index 1644364c..39ae36a4 100644 --- a/js/packages/truapi-host/src/web/worker-provider.test.ts +++ b/js/packages/truapi-host/src/web/worker-provider.test.ts @@ -799,17 +799,6 @@ describe("createWebWorkerPairingHostRuntime", () => { it("propagates host subscription stream errors to the worker", async () => { const worker = new FakeWorker(); - let resolveSubscriptionError!: (message: WorkerMessage) => void; - const subscriptionErrorSeen = new Promise((resolve) => { - resolveSubscriptionError = resolve; - }); - const postMessage = worker.postMessage.bind(worker); - worker.postMessage = (message) => { - postMessage(message); - if (message.kind === "subscriptionError") { - resolveSubscriptionError(message); - } - }; const providerPromise = createProviderFromRuntime( asWorker(worker), makeHostCallbacks({ @@ -836,7 +825,7 @@ describe("createWebWorkerPairingHostRuntime", () => { await settle(); - expect(await subscriptionErrorSeen).toEqual({ + expect(worker.messages.at(-1)).toEqual({ kind: "subscriptionError", subId: 7, error: "theme stream failed", diff --git a/playground/package.json b/playground/package.json index 78fef5f3..bc1116a4 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", @@ -41,6 +41,7 @@ "@types/react-dom": "^19", "eslint": "^9", "eslint-config-next": "^15.5.4", + "jsqr": "^1.4.0", "typescript": "^6.0" }, "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e" 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..12d901a1 100644 --- a/playground/src/lib/auto-test.ts +++ b/playground/src/lib/auto-test.ts @@ -15,13 +15,16 @@ export interface TestEntry { const UNARY_TIMEOUT_MS = 10_000; const SIGNING_TIMEOUT_MS = 30_000; const SSO_TIMEOUT_MS = 60_000; +const LIVE_ALLOCATION_TIMEOUT_MS = 240_000; // Services skipped wholesale in the diagnosis until hosts wire them up. -const SKIPPED_SERVICES = new Set(["Coin Payment"]); -// 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. +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 that trigger a host permission/signing prompt, so they need the +// longer signing-class timeout to allow for the user to respond. const LONG_TIMEOUT_METHODS = new Set([ + "Account/get_account", "Account/get_account_alias", "Resource Allocation/request", "Signing/sign_payload", @@ -35,6 +38,13 @@ const LONG_TIMEOUT_METHODS = new Set([ const METHOD_TIMEOUT_MS = new Map([ ["Account/get_account_alias", SSO_TIMEOUT_MS], + ["Resource Allocation/request", LIVE_ALLOCATION_TIMEOUT_MS], + ["Preimage/lookup_subscribe", SSO_TIMEOUT_MS], + ["Preimage/submit", SSO_TIMEOUT_MS], + ["Signing/create_transaction", SSO_TIMEOUT_MS], + ["Statement Store/create_proof_authorized", LIVE_ALLOCATION_TIMEOUT_MS], + ["Statement Store/submit", LIVE_ALLOCATION_TIMEOUT_MS], + ["Statement Store/subscribe", LIVE_ALLOCATION_TIMEOUT_MS], ]); type RunOneOpts = { @@ -54,6 +64,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/dotli-diagnosis.ts b/playground/tests/e2e/dotli-diagnosis.ts new file mode 100644 index 00000000..3e9521d6 --- /dev/null +++ b/playground/tests/e2e/dotli-diagnosis.ts @@ -0,0 +1,760 @@ +// Copyright 2026 Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: AGPL-3.0-only + +import { + chromium, + type BrowserContext, + type Frame, + type Page, +} from "playwright"; +import { spawn, type ChildProcess } from "node:child_process"; +import { existsSync, mkdirSync, writeFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { extractQrPayload } from "./dotli/helpers/extract-qr-payload"; +import { + disconnect, + generateUsername, + health, + pair, + type PairResult, +} from "./dotli/helpers/signer-bot"; + +const currentDir = dirname(fileURLToPath(import.meta.url)); +const repoRoot = resolve(currentDir, "../../.."); +const playgroundRoot = resolve(repoRoot, "playground"); +const dotliRoot = resolve(repoRoot, "hosts/dotli"); +const outputDir = resolve(playgroundRoot, "test-results/e2e-dotli"); +const screenshotsDir = resolve(outputDir, "screenshots"); + +const hostPort = process.env.E2E_DOTLI_HOST_PORT ?? process.env.PORT ?? "5173"; +const playgroundPort = process.env.E2E_DOTLI_PLAYGROUND_PORT ?? "3000"; +const headless = process.env.HEADED === "1" ? false : true; +const slowMo = process.env.SLOWMO ? Number(process.env.SLOWMO) : 0; +const smokeOnly = process.env.E2E_DOTLI_SMOKE === "1"; +const defaultBotBase = "https://signing-bot-dev.novasama-tech.org/"; +const defaultBotNetwork = "paseo-next-v2"; +const loginUserBadgeTimeoutMs = Number( + process.env.E2E_DOTLI_LOGIN_TIMEOUT_MS ?? "240000", +); + +const botToken = readEnv("SIGNER_BOT_SVC_TOKEN"); +const botBase = process.env.SIGNER_BOT_BASE_URL ?? defaultBotBase; +const botNetwork = process.env.SIGNER_BOT_NETWORK ?? defaultBotNetwork; + +const serverProcesses: ChildProcess[] = []; +const pageErrors: string[] = []; +const browserLogs: string[] = []; +const screenshots: string[] = []; +let screenshotSeq = 0; + +type PlaygroundE2E = { + waitForConnectionStatus?: ( + status: "disconnected" | "connecting" | "connected", + timeoutMs?: number, + ) => Promise; + startAccountConnectionStatusProbe?: unknown; +}; + +declare global { + interface Window { + __dotliE2eAuthStates?: unknown[]; + __truapiPlaygroundE2E?: PlaygroundE2E; + __TRUAPI_PLAYGROUND_E2E__?: boolean; + } +} + +function readEnv(name: string): string | undefined { + const value = process.env[name]; + if (!value && !smokeOnly) { + throw new Error( + `${name} is required for fully automated e2e-dotli. ` + + "This suite pairs through signer-bot; without it, a human phone scan is required.", + ); + } + return value; +} + +function requireBotEnv(): { + token: string; + base: string; + network: string; +} { + if (botToken === undefined) { + throw new Error( + "SIGNER_BOT_SVC_TOKEN is required outside E2E_DOTLI_SMOKE=1.", + ); + } + return { token: botToken, base: botBase, network: botNetwork }; +} + +function startServer( + label: string, + command: string, + args: string[], + cwd: string, + env: NodeJS.ProcessEnv = {}, +): ChildProcess { + const child = spawn(command, args, { + cwd, + env: { ...process.env, ...env }, + stdio: ["ignore", "pipe", "pipe"], + }); + serverProcesses.push(child); + + const prefix = `[${label}]`; + child.stdout?.on("data", (chunk: Buffer) => { + process.stdout.write( + chunk + .toString() + .split("\n") + .map((line) => (line.length > 0 ? `${prefix} ${line}` : line)) + .join("\n"), + ); + }); + child.stderr?.on("data", (chunk: Buffer) => { + process.stderr.write( + chunk + .toString() + .split("\n") + .map((line) => (line.length > 0 ? `${prefix} ${line}` : line)) + .join("\n"), + ); + }); + child.on("exit", (code, signal) => { + if (code !== null && code !== 0) { + console.error(`${prefix} exited with code ${code}`); + } else if (signal !== null && signal !== "SIGTERM") { + console.error(`${prefix} exited via ${signal}`); + } + }); + return child; +} + +async function waitForHttp(url: string, label: string): Promise { + const deadline = Date.now() + 120_000; + let last = ""; + while (Date.now() < deadline) { + for (const child of serverProcesses) { + if (child.exitCode !== null) { + throw new Error(`${label} failed to start; a server process exited`); + } + } + try { + const response = await fetch(url, { method: "GET" }); + if (response.ok) { + return; + } + last = `${response.status} ${response.statusText}`; + } catch (error) { + last = error instanceof Error ? error.message : String(error); + } + await sleep(1_000); + } + throw new Error(`${label} did not become ready at ${url}: ${last}`); +} + +async function sleep(ms: number): Promise { + await new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function captureStep(page: Page, name: string): Promise { + const safeName = name.replace(/[^a-z0-9_-]+/gi, "-").toLowerCase(); + const filename = `${String(++screenshotSeq).padStart(2, "0")}-${safeName}.png`; + const path = resolve(screenshotsDir, filename); + mkdirSync(screenshotsDir, { recursive: true }); + await page + .screenshot({ path, fullPage: true }) + .then(() => { + screenshots.push(path); + console.log(`[e2e-dotli] screenshot: ${path}`); + }) + .catch((error: unknown) => { + const message = error instanceof Error ? error.message : String(error); + console.warn(`[e2e-dotli] screenshot failed (${name}): ${message}`); + }); +} + +async function assertPortsFree(): Promise { + await Promise.all([ + assertPortFree(Number(hostPort), "dotli preview"), + assertPortFree(Number(playgroundPort), "playground"), + ]); +} + +async function assertPortFree(port: number, label: string): Promise { + try { + await fetch(`http://127.0.0.1:${port}/`, { method: "GET" }); + } catch { + return; + } + throw new Error( + `${label} port ${port} is already serving HTTP. Stop the stale process or set ` + + `${label === "dotli preview" ? "E2E_DOTLI_HOST_PORT" : "E2E_DOTLI_PLAYGROUND_PORT"}.`, + ); +} + +async function startLocalStack(): Promise { + await assertPortsFree(); + startServer("dotli", "bun", ["run", "preview:debug"], dotliRoot, { + PORT: hostPort, + }); + startServer("playground", "yarn", ["dev"], playgroundRoot, { + PORT: playgroundPort, + }); + await Promise.all([ + waitForHttp(`http://localhost:${hostPort}/`, "dotli preview"), + waitForHttp(`http://localhost:${playgroundPort}/`, "playground"), + ]); +} + +async function signOutIfNeeded(page: Page): Promise { + const badge = page.locator("#auth-button .user-badge"); + if (!(await badge.isVisible({ timeout: 2_000 }).catch(() => false))) { + return; + } + console.log( + "[e2e-dotli] existing session found; signing out through host UI", + ); + await page.evaluate(() => { + document.querySelector("#auth-button")?.click(); + }); + await page.locator("#user-popover-disconnect").waitFor({ + state: "visible", + timeout: 5_000, + }); + await page.evaluate(() => { + document + .querySelector("#user-popover-disconnect") + ?.click(); + }); + await badge.waitFor({ state: "hidden", timeout: 20_000 }); +} + +async function openLoginQr(page: Page): Promise { + const auth = page.locator("#auth-button"); + await auth.waitFor({ state: "visible", timeout: 30_000 }); + await page.waitForFunction( + () => !document.querySelector("#auth-button")?.disabled, + null, + { timeout: 30_000 }, + ); + await auth.click(); + + const qr = page.locator("#auth-modal-qr canvas"); + await qr.waitFor({ state: "visible", timeout: 30_000 }); + await captureStep(page, "login-qr"); + return await extractQrPayload(page, "#auth-modal-qr canvas"); +} + +async function signInWithBot(page: Page): Promise { + const { token, base, network } = requireBotEnv(); + const handshake = await openLoginQr(page); + const username = generateUsername(); + console.log(`[e2e-dotli] pairing signer-bot user ${username}`); + const result = await pair(base, token, { + handshake, + username, + network, + }); + try { + await waitForSignedIn(page, result); + await captureStep(page, "signed-in"); + } catch (error) { + await disconnect(base, token, result.sessionId); + throw error; + } + return result; +} + +async function waitForSignedIn(page: Page, result: PairResult): Promise { + try { + const existingFailure = await latestLoginFailureReason(page); + if (existingFailure !== null) { + throw new Error(`Login failed: ${existingFailure}`); + } + await Promise.race([ + page + .locator("#auth-button .user-badge") + .waitFor({ state: "visible", timeout: loginUserBadgeTimeoutMs }), + page.evaluate( + () => + new Promise((_, reject) => { + const listener = (event: Event): void => { + const state = ( + event as CustomEvent< + { tag?: string; reason?: string } | undefined + > + ).detail; + if (state?.tag !== "LoginFailed") { + return; + } + window.removeEventListener("dotli:truapi-auth-state", listener); + reject(new Error(`Login failed: ${state.reason ?? "unknown"}`)); + }; + window.addEventListener("dotli:truapi-auth-state", listener); + }), + ), + ]); + } catch (error) { + await writeAuthDebug(page, { + stage: "post-pair-user-badge", + pairResult: redactedPairResult(result), + }); + throw error; + } +} + +async function latestLoginFailureReason(page: Page): Promise { + return await page.evaluate(() => { + const states = window.__dotliE2eAuthStates ?? []; + for (let i = states.length - 1; i >= 0; i--) { + const candidate = states[i] as { + detail?: { tag?: string; reason?: string }; + }; + if (candidate.detail?.tag === "LoginFailed") { + return candidate.detail.reason ?? "unknown"; + } + } + return null; + }); +} + +function redactedPairResult(result: PairResult): PairResult { + return { + sessionId: result.sessionId, + user: { + username: result.user.username, + network: result.user.network, + address: result.user.address, + publicKeyHex: result.user.publicKeyHex, + attested: result.user.attested, + }, + }; +} + +async function writeAuthDebug( + page: Page, + extra: Record, +): Promise { + const debug = await page.evaluate(() => { + const safeStorageValue = (key: string, value: string): string => { + if ( + key === "dotli:mode" || + key === "dotli:network" || + key === "dotli:chain-backend" || + key === "dotli:content-backend" || + key === "truapi:logLevel" || + key === "dotli:truapi-debug" + ) { + return value; + } + return "[redacted]"; + }; + const readStorage = (storage: Storage): Record => { + const values: Record = {}; + for (let i = 0; i < storage.length; i++) { + const key = storage.key(i); + if (key !== null && /dotli|truapi/i.test(key)) { + values[key] = safeStorageValue(key, storage.getItem(key) ?? ""); + } + } + return values; + }; + + const authButton = document.querySelector("#auth-button"); + const modal = document.querySelector("#auth-modal-backdrop"); + const modalReason = document.querySelector("#auth-modal-reason"); + return { + url: location.href, + authStates: window.__dotliE2eAuthStates ?? [], + authButtonHtml: authButton?.outerHTML ?? null, + authButtonText: authButton?.textContent ?? null, + modalClass: modal?.getAttribute("class") ?? null, + modalReasonText: modalReason?.textContent ?? null, + localStorage: readStorage(localStorage), + sessionStorage: readStorage(sessionStorage), + }; + }); + const metadataPath = resolve(outputDir, "auth-debug.json"); + writeFileSync( + metadataPath, + `${JSON.stringify({ ...debug, ...extra }, null, 2)}\n`, + ); + console.error(`[e2e-dotli] auth debug: ${metadataPath}`); +} + +function startHostModalClicker(page: Page): () => void { + let stopped = false; + void (async () => { + while (!stopped) { + await acceptVisibleHostModal(page); + await page.waitForTimeout(250).catch(() => {}); + } + })(); + return () => { + stopped = true; + }; +} + +async function drainHostModals(page: Page, timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if ( + !(await page + .locator(".signing-modal-backdrop") + .isVisible() + .catch(() => false)) + ) { + return; + } + if (!(await acceptVisibleHostModal(page))) { + await page.waitForTimeout(250); + } + } +} + +async function acceptVisibleHostModal(page: Page): Promise { + const allowedLabels = new Set(["Allow", "Create", "Sign"]); + const buttons = page.locator(".signing-modal-backdrop button"); + const count = await buttons.count().catch(() => 0); + for (let index = 0; index < count; index++) { + const button = buttons.nth(index); + const visible = await button.isVisible({ timeout: 100 }).catch(() => false); + const enabled = await button.isEnabled({ timeout: 100 }).catch(() => false); + if (!visible || !enabled) { + continue; + } + const label = (await button.innerText().catch(() => "")).trim(); + if (!allowedLabels.has(label)) { + continue; + } + console.log(`[e2e-dotli] accepting host modal: ${label}`); + await button.click({ timeout: 2_000 }).catch(() => {}); + return true; + } + return false; +} + +async function findPlaygroundFrame(page: Page) { + const deadline = Date.now() + 30_000; + while (Date.now() < deadline) { + const frame = page + .frames() + .find((candidate) => + candidate.url().startsWith(`http://localhost:${playgroundPort}/`), + ); + if (frame) { + const ready = await frame + .locator('[data-testid="diagnosis-entry"]') + .isVisible({ timeout: 500 }) + .catch(() => false); + if (ready) { + return frame; + } + } + await page.waitForTimeout(250); + } + throw new Error("playground iframe did not become ready"); +} + +async function waitForPlaygroundE2EHook(page: Page): Promise { + const frame = await findPlaygroundFrame(page); + await frame.waitForFunction(() => Boolean(window.__truapiPlaygroundE2E), { + timeout: 15_000, + }); + await frame.evaluate(async () => { + const hook = window.__truapiPlaygroundE2E; + if (!hook?.waitForConnectionStatus) { + throw new Error("playground e2e host connection hook is unavailable"); + } + await hook.waitForConnectionStatus("connected", 30_000); + }); +} + +async function assertHostSignOutAndReconnect(page: Page): Promise { + console.log("[e2e-dotli] validating host sign-out"); + await signOutIfNeeded(page); + await page + .locator("#auth-button .user-badge") + .waitFor({ state: "hidden", timeout: 20_000 }); + await captureStep(page, "signed-out"); + + console.log("[e2e-dotli] validating signer reconnect"); + return await signInWithBot(page); +} + +async function runDiagnosis(page: Page): Promise<{ + summary: string; + report: string; + copyReportClicked: boolean; +}> { + for (let attempt = 1; attempt <= 2; attempt++) { + try { + return await runDiagnosisOnce(page); + } catch (error) { + if (attempt === 2 || !isFrameDetachedError(error)) { + throw error; + } + console.warn( + "[e2e-dotli] playground iframe detached during diagnosis; retrying once", + ); + await captureStep(page, `diagnosis-frame-detached-attempt-${attempt}`); + await page.waitForTimeout(1_000); + } + } + throw new Error("diagnosis retry exhausted"); +} + +async function runDiagnosisOnce(page: Page): Promise<{ + summary: string; + report: string; + copyReportClicked: boolean; +}> { + const frame = await findPlaygroundFrame(page); + await captureStep(page, "diagnosis-ready"); + await frame.locator('[data-testid="diagnosis-entry"]').click(); + await frame.locator('[data-testid="diagnosis-run"]').click(); + await captureStep(page, "diagnosis-running"); + + await waitForDiagnosisReportReady(frame); + + const summary = await frame + .locator('[data-testid="diagnosis-summary"]') + .innerText({ timeout: 5_000 }); + await drainHostModals(page, 5_000); + await captureStep(page, "diagnosis-report-ready"); + const report = + (await frame + .locator('[data-testid="diagnosis-report-markdown"]') + .textContent({ timeout: 5_000 })) ?? ""; + if (report.trim().length === 0) { + throw new Error("diagnosis report markdown is empty"); + } + + await frame.locator('[data-testid="diagnosis-copy-report"]').click(); + + return { summary, report, copyReportClicked: true }; +} + +async function waitForDiagnosisReportReady(frame: Frame): Promise { + const deadline = Date.now() + 20 * 60_000; + let lastLogAt = 0; + while (Date.now() < deadline) { + const reportReady = await frame + .locator('[data-testid="diagnosis-copy-report"]') + .isVisible({ timeout: 1_000 }); + if (reportReady) { + return; + } + + const now = Date.now(); + if (now - lastLogAt >= 30_000) { + lastLogAt = now; + const progress = await frame.evaluate(() => { + const counts: Record = {}; + let running = "none"; + for (const row of document.querySelectorAll( + '[data-testid="diagnosis-row"]', + )) { + const status = row.dataset.status ?? "unknown"; + counts[status] = (counts[status] ?? 0) + 1; + if (status === "running") { + running = + row.querySelector(".diag__name")?.innerText ?? + "unknown"; + } + } + const parts = Object.entries(counts) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([status, count]) => `${status}=${count}`); + return `${parts.join(" ")} running=${running}`; + }); + console.log(`[e2e-dotli] diagnosis progress: ${progress}`); + } + + await sleep(5_000); + } + throw new Error("diagnosis did not finish within 20 minutes"); +} + +function isFrameDetachedError(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error); + return message.includes("Frame was detached"); +} + +async function main(): Promise { + mkdirSync(outputDir, { recursive: true }); + mkdirSync(screenshotsDir, { recursive: true }); + if (!smokeOnly) { + const { base, network } = requireBotEnv(); + console.log(`[e2e-dotli] bot=${base} network=${network}`); + const probe = await health(base); + if (!probe.ok) { + throw new Error(`signer-bot unavailable: ${probe.error ?? probe.status}`); + } + } else { + console.log("[e2e-dotli] smoke mode: validating local stack and QR only"); + } + + let browser: Awaited> | undefined; + let context: BrowserContext | undefined; + let pairResult: PairResult | undefined; + let page: Page | undefined; + try { + await startLocalStack(); + + const executablePath = existsSync("/usr/bin/chromium") + ? "/usr/bin/chromium" + : undefined; + browser = await chromium.launch({ + headless, + slowMo, + executablePath, + args: ["--no-sandbox"], + }); + context = await browser.newContext({ + serviceWorkers: "block", + permissions: ["camera", "clipboard-read", "clipboard-write"], + }); + page = await context.newPage(); + page.on("pageerror", (error) => { + const message = error.stack ?? error.message; + pageErrors.push(message); + console.error(`[browser:pageerror] ${message}`); + }); + page.on("console", (message) => { + const text = message.text(); + if ( + message.type() === "error" || + /\[truapi|\[dotli|\[dot\.li|statement.store|signing/i.test(text) + ) { + const line = `[browser:${message.type()}] ${text}`; + browserLogs.push(line); + console.log(line); + } + }); + + await page.addInitScript( + ({ playgroundPort }) => { + try { + const playgroundLabel = `localhost:${playgroundPort}`; + localStorage.setItem("dotli:mode", "gateway"); + localStorage.setItem("dotli:chain-backend", "rpc-gateway"); + localStorage.setItem("dotli:content-backend", "ipfs-gateway"); + localStorage.setItem( + `dotli:permissions:${playgroundLabel}`, + JSON.stringify({ Camera: "granted" }), + ); + localStorage.setItem("desktop-banner-dismissed", "1"); + sessionStorage.removeItem("dotli:truapi-debug"); + localStorage.setItem("truapi:logLevel", "debug"); + localStorage.setItem("truapi:playground:e2e", "1"); + window.__TRUAPI_PLAYGROUND_E2E__ = true; + window.__dotliE2eAuthStates = []; + window.addEventListener("dotli:truapi-auth-state", (event: Event) => { + window.__dotliE2eAuthStates?.push({ + timestamp: Date.now(), + detail: (event as CustomEvent).detail, + }); + }); + } catch { + /* ignore */ + } + }, + { playgroundPort }, + ); + + const params = new URLSearchParams({ + chainBackend: "rpc-gateway", + e2e: String(Date.now()), + network: botNetwork, + }); + const url = `http://localhost:${hostPort}/localhost:${playgroundPort}?${params.toString()}`; + await page.goto(url, { timeout: 60_000, waitUntil: "domcontentloaded" }); + await captureStep(page, "loaded"); + await signOutIfNeeded(page); + if (smokeOnly) { + const handshake = await openLoginQr(page); + const metadataPath = resolve(outputDir, "smoke-run.json"); + writeFileSync( + metadataPath, + `${JSON.stringify( + { + mode: "smoke", + handshakePrefix: handshake.slice(0, 32), + pageErrors, + browserLogs, + timestamp: new Date().toISOString(), + }, + null, + 2, + )}\n`, + ); + console.log(`[e2e-dotli] smoke complete: ${metadataPath}`); + if (pageErrors.length > 0) { + throw new Error(`browser page errors occurred: ${pageErrors.length}`); + } + return; + } + await waitForPlaygroundE2EHook(page); + pairResult = await signInWithBot(page); + const stopClicker = startHostModalClicker(page); + try { + const { summary, report, copyReportClicked } = await runDiagnosis(page); + const reportPath = resolve(outputDir, "diagnosis-report.md"); + writeFileSync(reportPath, report); + pairResult = await assertHostSignOutAndReconnect(page); + const metadataPath = resolve(outputDir, "diagnosis-run.json"); + writeFileSync( + metadataPath, + `${JSON.stringify( + { + summary, + reportPath, + copyReportClicked, + screenshots, + user: redactedPairResult(pairResult).user, + sessionLifecycle: "host-sign-out-reconnect", + pageErrors, + browserLogs, + timestamp: new Date().toISOString(), + }, + null, + 2, + )}\n`, + ); + console.log(`[e2e-dotli] diagnosis complete: ${summary}`); + console.log(`[e2e-dotli] report: ${reportPath}`); + if (pageErrors.length > 0) { + throw new Error(`browser page errors occurred: ${pageErrors.length}`); + } + } finally { + stopClicker(); + } + } catch (error) { + if (page) { + await captureStep(page, "failure"); + } + throw error; + } finally { + if (pairResult) { + const { token, base } = requireBotEnv(); + await disconnect(base, token, pairResult.sessionId); + } + await context?.close().catch(() => {}); + await browser?.close().catch(() => {}); + for (const child of serverProcesses) { + child.kill("SIGTERM"); + } + } +} + +main().catch((error: unknown) => { + const message = + error instanceof Error ? (error.stack ?? error.message) : String(error); + mkdirSync(dirname(resolve(outputDir, "failure.log")), { recursive: true }); + writeFileSync(resolve(outputDir, "failure.log"), `${message}\n`); + console.error(`[e2e-dotli] ${message}`); + process.exit(1); +}); diff --git a/playground/tests/e2e/dotli/helpers/extract-qr-payload.ts b/playground/tests/e2e/dotli/helpers/extract-qr-payload.ts new file mode 100644 index 00000000..fb94678d --- /dev/null +++ b/playground/tests/e2e/dotli/helpers/extract-qr-payload.ts @@ -0,0 +1,44 @@ +// Copyright 2026 Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: AGPL-3.0-only + +import type { Page } from "@playwright/test"; +import jsQR from "jsqr"; + +export async function extractQrPayload( + page: Page, + canvasSelector: string, + timeoutMs = 30_000, +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const embedded = await page + .locator(canvasSelector) + .getAttribute("data-qr-payload", { timeout: 250 }) + .catch(() => null); + if (embedded?.startsWith("polkadotapp://")) { + return embedded; + } + + const px = await page.evaluate((sel) => { + const canvas = document.querySelector(sel) as HTMLCanvasElement | null; + if (!canvas || canvas.width === 0) return null; + const ctx = canvas.getContext("2d"); + if (!ctx) return null; + const img = ctx.getImageData(0, 0, canvas.width, canvas.height); + return { + data: Array.from(img.data), + width: img.width, + height: img.height, + }; + }, canvasSelector); + + if (px) { + const code = jsQR(new Uint8ClampedArray(px.data), px.width, px.height); + if (code?.data?.startsWith("polkadotapp://")) { + return code.data; + } + } + await page.waitForTimeout(1_000); + } + throw new Error("Could not decode QR payload from canvas"); +} diff --git a/playground/tests/e2e/dotli/helpers/signer-bot.ts b/playground/tests/e2e/dotli/helpers/signer-bot.ts new file mode 100644 index 00000000..7560a4b7 --- /dev/null +++ b/playground/tests/e2e/dotli/helpers/signer-bot.ts @@ -0,0 +1,278 @@ +// Copyright 2026 Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: AGPL-3.0-only + +import { setTimeout as sleep } from "node:timers/promises"; + +const TRANSIENT = new Set([502, 503, 504]); + +// Per-attempt request timeout. First-time pair can include user creation and +// People-chain attestation, so give the one-shot handshake room to finish +// instead of aborting and retrying the same QR payload. +const PAIR_REQUEST_TIMEOUT_MS = Number( + process.env.SIGNER_BOT_PAIR_TIMEOUT_MS ?? "120000", +); +const HEALTH_REQUEST_TIMEOUT_MS = 5_000; + +function shellQuote(value: string): string { + if (value.length === 0) return "''"; + return `'${value.replace(/'/g, "'\\''")}'`; +} + +function buildPairCurl(url: string, body: unknown): string { + return [ + `curl -sS -X POST ${shellQuote(url)}`, + '-H "Authorization: Bearer ${SIGNER_BOT_SVC_TOKEN}"', + "-H 'Content-Type: application/json'", + "-H 'Accept: application/json'", + `--data-raw ${shellQuote(JSON.stringify(body))}`, + ].join(" \\\n "); +} + +const SECRET_FIELD_NAMES = new Set([ + "mnemonic", + "privatekey", + "secret", + "secretphrase", + "secretseed", + "secreturi", + "seed", + "suri", +]); + +const QUOTED_JSON_FIELD = /"((?:\\.|[^"\\])+)"\s*:\s*"((?:\\.|[^"\\])*)"/g; + +function normalizeSecretFieldName(name: string): string { + return name.replace(/[-_]/g, "").toLowerCase(); +} + +function isSecretFieldName(name: string): boolean { + return SECRET_FIELD_NAMES.has(normalizeSecretFieldName(name)); +} + +export function redactSignerBotResponse(text: string): string { + try { + return JSON.stringify(JSON.parse(text), (key, value) => + isSecretFieldName(key) ? "[redacted]" : value, + ); + } catch { + return text.replace(QUOTED_JSON_FIELD, (field, name: string) => + isSecretFieldName(name) ? `"${name}":"[redacted]"` : field, + ); + } +} + +async function fetchWithTimeout( + url: string, + init: RequestInit, + timeoutMs: number, +): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + return await fetch(url, { ...init, signal: controller.signal }); + } finally { + clearTimeout(timer); + } +} + +interface TextResponse { + response: Response; + text: string; +} + +async function fetchTextWithTimeout( + url: string, + init: RequestInit, + timeoutMs: number, +): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + const response = await fetch(url, { ...init, signal: controller.signal }); + const text = await response.text(); + return { response, text }; + } finally { + clearTimeout(timer); + } +} + +async function fetchTextRetry( + url: string, + init: RequestInit, + attempts = 4, + timeoutMs = PAIR_REQUEST_TIMEOUT_MS, +): Promise { + let last: unknown = null; + for (let i = 1; i <= attempts; i++) { + try { + const result = await fetchTextWithTimeout(url, init, timeoutMs); + const { response, text } = result; + if (response.ok || !TRANSIENT.has(response.status) || i === attempts) { + return result; + } + console.warn( + `[bot] ${init.method ?? "GET"} ${url} response ${response.status} ${response.statusText} (attempt ${i}/${attempts}): ${redactSignerBotResponse(text)}`, + ); + } catch (e) { + last = e; + if (i === attempts) throw e; + console.warn( + `[bot] ${init.method ?? "GET"} ${url} threw "${(e as Error).message}" (attempt ${i}/${attempts})`, + ); + } + await sleep(1_000 * 2 ** (i - 1)); + } + throw last ?? new Error("fetchTextRetry exhausted"); +} + +/** + * Generate a per-run username for the Nova signing bot. + * + * Each test run gets its own throwaway user so network state (allowances, + * permissions, derived product accounts) doesn't leak between PRs. The + * format is `dotlitests` followed by 6 lowercase letters, a namespace of + * roughly 3·10^8 with no collisions in practice. It stays inside the bot's + * strictest username regex (^[a-z]+$) so it works for both regular + * `username` and `liteUsername` fields if we ever want one. + */ +export function generateUsername(): string { + const alphabet = "abcdefghijklmnopqrstuvwxyz"; + let suffix = ""; + for (let i = 0; i < 6; i++) { + suffix += alphabet[Math.floor(Math.random() * alphabet.length)]; + } + return `dotlitests${suffix}`; +} + +export interface PairResult { + sessionId: string; + user: { + username: string; + network: string; + address: string; + publicKeyHex: string; + attested?: boolean; + }; +} + +/** + * Pair the bot with a dot.li session via the QR-derived handshake deeplink. + * + * The Nova bot's `/api/pair` is one-shot: given a handshake, it + * (a) creates the user if `username` is new, (b) attests the account on + * People chain so it has Statement Store allowance, (c) completes the + * SSO handshake, (d) starts auto-signing future SignRequests for that + * session. No separate provisioning / poll step needed. + * + * `network` should match dot.li's default network (`paseo-next-v2` at time + * of writing). The bot's `/api/networks` endpoint lists supported IDs. + */ +export async function pair( + base: string, + svcToken: string, + args: { handshake: string; username: string; network: string }, +): Promise { + const url = `${base.replace(/\/$/, "")}/api/pair`; + console.log(`[bot] /api/pair curl:\n${buildPairCurl(url, args)}`); + const init: RequestInit = { + method: "POST", + headers: { + Authorization: `Bearer ${svcToken}`, + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify(args), + }; + let result: TextResponse; + try { + result = await fetchTextRetry(url, init); + } catch (e) { + console.error( + `[bot] /api/pair response unavailable: ${(e as Error).message}`, + ); + throw e; + } + const { response: r, text } = result; + console.log( + `[bot] /api/pair raw response ${r.status} ${r.statusText}: ${redactSignerBotResponse(text) || ""}`, + ); + if (!r.ok) { + throw new Error( + `pair ${r.status}: ${redactSignerBotResponse(text) || ""}`, + ); + } + return JSON.parse(text) as PairResult; +} + +/** + * Tear down a bot session at the end of a test worker. + * + * Best-effort. Failure to disconnect is non-fatal, the bot times + * sessions out anyway. + */ +export async function disconnect( + base: string, + svcToken: string, + sessionId: string, +): Promise { + await disconnectStrict(base, svcToken, sessionId).catch(() => {}); +} + +export async function disconnectStrict( + base: string, + svcToken: string, + sessionId: string, +): Promise { + const response = await fetchWithTimeout( + `${base.replace(/\/$/, "")}/api/disconnect`, + { + method: "POST", + headers: { + Authorization: `Bearer ${svcToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ sessionId }), + }, + PAIR_REQUEST_TIMEOUT_MS, + ); + if (!response.ok) { + throw new Error(`disconnect ${response.status}: ${await response.text()}`); + } + const body = (await response.json()) as { disconnected?: boolean }; + if (body.disconnected !== true) { + throw new Error(`disconnect did not find session ${sessionId}`); + } +} + +export interface BotHealth { + ok: boolean; + status?: string; + uptime?: number; + error?: string; +} + +/** + * Lightweight bot reachability probe. Used by globalSetup to fail-fast + * when the bot is unreachable, distinguishing "Nova is down" from + * "dot.li is broken" in CI output. No auth required. + */ +export async function health(base: string): Promise { + try { + const r = await fetchWithTimeout( + `${base.replace(/\/$/, "")}/api/health`, + { method: "GET", headers: { Accept: "application/json" } }, + HEALTH_REQUEST_TIMEOUT_MS, + ); + if (!r.ok) { + return { ok: false, error: `${r.status} ${r.statusText}` }; + } + const body = (await r.json()) as { status?: string; uptime?: number }; + return { + ok: body.status === "ok", + status: body.status, + uptime: body.uptime, + }; + } catch (e) { + return { ok: false, error: (e as Error).message }; + } +} diff --git a/playground/tests/e2e/helpers.ts b/playground/tests/e2e/helpers.ts index a5cc3801..c61550ef 100644 --- a/playground/tests/e2e/helpers.ts +++ b/playground/tests/e2e/helpers.ts @@ -4,11 +4,27 @@ import { expect, type FrameLocator, type Page } from "@playwright/test"; * Open the playground inside dotli's iframe shell and wait for it to mount. * * The dotli host parses `/localhost:` as a proxy directive and iframes - * `http://localhost:3000`. We hand back the FrameLocator scoped to that - * iframe so individual specs only need to know about playground selectors. + * `http://localhost:3000`. The `dotliProductId` query param is host-only; it + * makes local e2e use the same product id as the deployed playground examples. + * We hand back the FrameLocator scoped to that iframe so individual specs only + * need to know about playground selectors. */ export async function openPlaygroundInDotli(page: Page): Promise { - await page.goto("/localhost:3000"); + 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?dotliProductId=truapi-playground.dot"); // dotli renders an additional hidden iframe (host.localhost:5173?mode=direct) // alongside the proxied playground; scope to the playground src so the // FrameLocator is unique under Playwright strict mode. diff --git a/playground/yarn.lock b/playground/yarn.lock index 01d5a21a..6f4162c8 100644 --- a/playground/yarn.lock +++ b/playground/yarn.lock @@ -1920,6 +1920,11 @@ json5@^1.0.2: dependencies: minimist "^1.2.0" +jsqr@^1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/jsqr/-/jsqr-1.4.0.tgz#8efb8d0a7cc6863cb6d95116b9069123ce9eb2d1" + integrity sha512-dxLob7q65Xg2DvstYkRpkYtmKm2sPJ9oFhrhmudT1dZvNFFTlroai3AWSpLey/w5vMcLBXRgOJsbXpdN9HzU/A== + "jsx-ast-utils@^2.4.1 || ^3.0.0", jsx-ast-utils@^3.3.5: version "3.3.5" resolved "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz" 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 1c1867ce..1ac2e496 100644 --- a/rust/crates/truapi-codegen/tests/golden/host-callbacks-adapter.ts +++ b/rust/crates/truapi-codegen/tests/golden/host-callbacks-adapter.ts @@ -16,10 +16,7 @@ import { RemotePermissionResponse, ThemeVariant, } from "@parity/truapi"; -import type { - GenericError, - NotificationId, -} from "@parity/truapi"; +import type { GenericError, NotificationId } from "@parity/truapi"; import { AuthState, CoreStorageKey, @@ -30,13 +27,8 @@ import type { RequiredHostCallbacks, } from "./host-callbacks.js"; -import type { - ChainConnect, -} from "../runtime.js"; -import { - chainConnectAdapter, - driveResultStream, -} from "../adapter-support.js"; +import type { ChainConnect } from "../runtime.js"; +import { chainConnectAdapter, driveResultStream } from "../adapter-support.js"; export interface RawCallbacks { authStateChanged(state: Uint8Array): void; @@ -50,12 +42,22 @@ export interface RawCallbacks { cancelNotification(id: NotificationId): Promise; devicePermission(request: Uint8Array): Promise; remotePermission(request: Uint8Array): Promise; - submitPreimage(value: Uint8Array, bulletinAllowanceSigner: BulletinAllowanceSigner): Promise; - lookupPreimage(key: Uint8Array, sendItem: (item?: Uint8Array) => void, sendError: (error: GenericError) => void): (() => void) | void; + submitPreimage( + value: Uint8Array, + bulletinAllowanceSigner: BulletinAllowanceSigner, + ): Promise; + lookupPreimage( + key: Uint8Array, + sendItem: (item?: Uint8Array) => void, + sendError: (error: GenericError) => void, + ): (() => void) | void; read(key: string): Promise; write(key: string, value: Uint8Array): Promise; clear(key: string): Promise; - subscribeTheme(sendItem: (item?: Uint8Array) => void, sendError: (error: GenericError) => void): (() => void) | void; + subscribeTheme( + sendItem: (item?: Uint8Array) => void, + sendError: (error: GenericError) => void, + ): (() => void) | void; confirmUserAction(review: Uint8Array): Promise; } /** Adapt typed host callbacks into the raw SCALE callback surface the @@ -64,23 +66,66 @@ export function createWasmRawCallbacks( callbacks: RequiredHostCallbacks, ): RawCallbacks { return { - authStateChanged: async (state) => await callbacks.auth.authStateChanged(AuthState.dec(state)), + 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))), + 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, bulletinAllowanceSigner) => await callbacks.preimage.submitPreimage(value, bulletinAllowanceSigner), - lookupPreimage: (key, sendItem, sendError) => driveResultStream(callbacks.preimage.lookupPreimage(key), sendItem, sendError), + 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, bulletinAllowanceSigner) => + await callbacks.preimage.submitPreimage(value, bulletinAllowanceSigner), + lookupPreimage: (key, sendItem, sendError) => + driveResultStream( + callbacks.preimage.lookupPreimage(key), + sendItem, + sendError, + ), read: async (key) => await callbacks.productStorage.read(key), - write: async (key, value) => await callbacks.productStorage.write(key, value), + write: async (key, value) => + await callbacks.productStorage.write(key, value), clear: async (key) => await callbacks.productStorage.clear(key), - subscribeTheme: (sendItem, sendError) => driveResultStream(callbacks.theme.subscribeTheme(), (item) => sendItem(ThemeVariant.enc(item)), sendError), - confirmUserAction: async (review) => await callbacks.userConfirmation.confirmUserAction(UserConfirmationReview.dec(review)), + subscribeTheme: (sendItem, sendError) => + driveResultStream( + callbacks.theme.subscribeTheme(), + (item) => sendItem(ThemeVariant.enc(item)), + sendError, + ), + confirmUserAction: async (review) => + await callbacks.userConfirmation.confirmUserAction( + UserConfirmationReview.dec(review), + ), }; } diff --git a/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts b/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts index 94b7814a..d4365287 100644 --- a/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts +++ b/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts @@ -110,7 +110,10 @@ export type CoreStorageKey = /** * Persisted authorization for one product-scoped permission request. */ - | { tag: "PermissionAuthorization"; value: { productId: string; request: PermissionAuthorizationRequest } } + | { + tag: "PermissionAuthorization"; + value: { productId: string; request: PermissionAuthorizationRequest }; + } /** * Persisted allowance-slot keys for one paired SSO session. */ @@ -167,7 +170,10 @@ export type PermissionAuthorizationRequest = * `NotDetermined` means the core has no persisted answer and will prompt the * host the next time the product requests this permission. */ -export type PermissionAuthorizationStatus = "NotDetermined" | "Denied" | "Authorized"; +export type PermissionAuthorizationStatus = + | "NotDetermined" + | "Denied" + | "Authorized"; /** * Review shown before a preimage is submitted. @@ -271,19 +277,39 @@ export type UserConfirmationReview = /** * Review shown before a product asks to access another product account. */ -export const AccountAccessReview: S.Codec = S.lazy((): S.Codec => S.Struct({requestingProductId: S.str, targetProductId: S.str}) as S.Codec); +export const AccountAccessReview: S.Codec = S.lazy( + (): S.Codec => + S.Struct({ + requestingProductId: S.str, + targetProductId: S.str, + }) as S.Codec, +); /** * Review shown before a product asks to alias another product account. */ -export const AccountAliasReview: S.Codec = S.lazy((): S.Codec => S.Struct({requestingProductId: S.str, targetProductId: S.str}) as S.Codec); +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 }>})); +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; @@ -292,23 +318,58 @@ export const AuthState: S.Codec = S.lazy((): S.Codec => S. * 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 }>, AllowanceKeys: S.Struct({sessionId: S.str}) as S.Codec<{ sessionId: string }>, LastProcessedPairingStatement: S._void})); +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; + }>, + AllowanceKeys: S.Struct({ sessionId: S.str }) as S.Codec<{ + sessionId: string; + }>, + LastProcessedPairingStatement: S._void, + }), +); /** * Review shown before a transaction-creation request is sent to the paired wallet. */ -export const CreateTransactionReview: S.Codec = S.lazy((): S.Codec => S.TaggedUnion({Product: ProductAccountTxPayload, LegacyAccount: LegacyAccountTxPayload})); +export const CreateTransactionReview: 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); +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, IdentityDisclosure: S._void})); +export const PermissionAuthorizationRequest: S.Codec = + S.lazy( + (): S.Codec => + S.TaggedUnion({ + Device: HostDevicePermissionRequest, + Remote: RemotePermissionRequest, + IdentityDisclosure: S._void, + }), + ); /** * Authorization status for a permission request. @@ -316,33 +377,72 @@ export const PermissionAuthorizationRequest: S.Codec = S.lazy((): S.Codec => S.Status("NotDetermined", "Denied", "Authorized")); +export const PermissionAuthorizationStatus: S.Codec = + S.lazy( + (): S.Codec => + S.Status("NotDetermined", "Denied", "Authorized"), + ); /** * Review shown before a preimage is submitted. */ -export const PreimageSubmitReview: S.Codec = S.lazy((): S.Codec => S.Struct({size: S.u64}) as S.Codec); +export const PreimageSubmitReview: 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); +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. */ -export const SignPayloadReview: S.Codec = S.lazy((): S.Codec => S.TaggedUnion({Product: HostSignPayloadRequest, LegacyAccount: HostSignPayloadWithLegacyAccountRequest})); +export const SignPayloadReview: S.Codec = S.lazy( + (): S.Codec => + S.TaggedUnion({ + Product: HostSignPayloadRequest, + LegacyAccount: HostSignPayloadWithLegacyAccountRequest, + }), +); /** * Review shown before a sign-raw request is sent to the paired wallet. */ -export const SignRawReview: S.Codec = S.lazy((): S.Codec => S.TaggedUnion({Product: HostSignRawRequest, LegacyAccount: HostSignRawWithLegacyAccountRequest})); +export const SignRawReview: S.Codec = S.lazy( + (): S.Codec => + S.TaggedUnion({ + Product: HostSignRawRequest, + LegacyAccount: HostSignRawWithLegacyAccountRequest, + }), +); /** * Review shown before a user-confirmed core action continues. */ -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, AccountAccess: AccountAccessReview})); +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, + AccountAccess: AccountAccessReview, + }), +); /** * Host auth UI driven by core-owned `AuthState` transitions. @@ -389,20 +489,27 @@ export interface CoreAdmin { /** * Read a stored permission authorization status without prompting. */ - getPermissionAuthorizationStatus(request: PermissionAuthorizationRequest): Promise; + getPermissionAuthorizationStatus( + request: PermissionAuthorizationRequest, + ): Promise; /** * Read stored permission authorization statuses without prompting. * * Results are returned in the same order as `requests`. */ - getPermissionAuthorizationStatuses(requests: Array): Promise>; + getPermissionAuthorizationStatuses( + requests: Array, + ): Promise>; /** * Update a stored permission authorization status. `NotDetermined` clears * the stored value so the next product request prompts again. */ - setPermissionAuthorizationStatus(request: PermissionAuthorizationRequest, status: PermissionAuthorizationStatus): Promise; + setPermissionAuthorizationStatus( + request: PermissionAuthorizationRequest, + status: PermissionAuthorizationStatus, + ): Promise; } /** @@ -433,7 +540,9 @@ export interface Features { /** * Report whether the requested feature is supported. */ - featureSupported(request: HostFeatureSupportedRequest): Promise; + featureSupported( + request: HostFeatureSupportedRequest, + ): Promise; } /** @@ -479,7 +588,9 @@ export interface Notifications { * Schedule or immediately display the given notification and return the * host-assigned id. */ - pushNotification(notification: HostPushNotificationRequest): Promise; + pushNotification( + notification: HostPushNotificationRequest, + ): Promise; /** * Cancel a notification by id. Idempotent: cancelling an already-fired or @@ -515,12 +626,16 @@ export interface Permissions { /** * Prompt the user for a device-level permission. */ - devicePermission(request: HostDevicePermissionRequest): Promise; + devicePermission( + request: HostDevicePermissionRequest, + ): Promise; /** * Prompt the user for a remote (product-scoped) permission bundle. */ - remotePermission(request: RemotePermissionRequest): Promise; + remotePermission( + request: RemotePermissionRequest, + ): Promise; } /** @@ -531,12 +646,17 @@ export interface PreimageHost { /** * Submit the preimage and return its key. */ - submitPreimage?(value: Uint8Array, bulletinAllowanceSigner: BulletinAllowanceSigner): Promise; + submitPreimage?( + value: Uint8Array, + bulletinAllowanceSigner: BulletinAllowanceSigner, + ): Promise; /** * Emits current value/miss immediately, then future updates. */ - lookupPreimage(key: Uint8Array): AsyncIterable>; + lookupPreimage( + key: Uint8Array, + ): AsyncIterable>; } /** diff --git a/rust/crates/truapi-codegen/tests/golden/wasm_bridge.rs b/rust/crates/truapi-codegen/tests/golden/wasm_bridge.rs index eedd768b..e01cf767 100644 --- a/rust/crates/truapi-codegen/tests/golden/wasm_bridge.rs +++ b/rust/crates/truapi-codegen/tests/golden/wasm_bridge.rs @@ -11,10 +11,9 @@ use truapi::v01; use wasm_bindgen::JsValue; use super::{ - WasmPlatform, bulletin_allowance_signer_to_js, 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, + WasmPlatform, bulletin_allowance_signer_to_js, 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 diff --git a/rust/crates/truapi-codegen/tests/golden/worker-callbacks.ts b/rust/crates/truapi-codegen/tests/golden/worker-callbacks.ts index 602f6145..135af163 100644 --- a/rust/crates/truapi-codegen/tests/golden/worker-callbacks.ts +++ b/rust/crates/truapi-codegen/tests/golden/worker-callbacks.ts @@ -15,9 +15,7 @@ export interface WorkerBulletinAllowanceSigner { signerId: number; } -import type { - ChainConnect, -} from "../runtime.js"; +import type { ChainConnect } from "../runtime.js"; export const CALLBACK_NAMES = [ "authStateChanged", @@ -36,17 +34,19 @@ export const CALLBACK_NAMES = [ "clear", "confirmUserAction", ] as const; -export type CallbackName = typeof CALLBACK_NAMES[number]; +export type CallbackName = (typeof CALLBACK_NAMES)[number]; -export const SUBSCRIPTION_NAMES = [ - "lookupPreimage", - "subscribeTheme", -] as const; -export type SubscriptionName = typeof SUBSCRIPTION_NAMES[number]; +export const SUBSCRIPTION_NAMES = ["lookupPreimage", "subscribeTheme"] as const; +export type SubscriptionName = (typeof SUBSCRIPTION_NAMES)[number]; export interface WorkerCallbackBridge { - callbackRequest(name: CallbackName, args: readonly unknown[]): Promise; - registerBulletinAllowanceSigner(signer: BulletinAllowanceSigner): WorkerBulletinAllowanceSigner; + callbackRequest( + name: CallbackName, + args: readonly unknown[], + ): Promise; + registerBulletinAllowanceSigner( + signer: BulletinAllowanceSigner, + ): WorkerBulletinAllowanceSigner; startSubscription( name: SubscriptionName, payload: Uint8Array | null, @@ -56,42 +56,73 @@ export interface WorkerCallbackBridge { chainConnect: ChainConnect; } -function rawCallbacks(bridge: WorkerCallbackBridge): Required> { +function rawCallbacks( + bridge: WorkerCallbackBridge, +): Required> { return { authStateChanged: (state) => void bridge.callbackRequest("authStateChanged", [state]).catch(() => {}), readCoreStorage: (key) => - bridge.callbackRequest("readCoreStorage", [key]) as ReturnType, + bridge.callbackRequest("readCoreStorage", [key]) as ReturnType< + RawCallbacks["readCoreStorage"] + >, writeCoreStorage: (key, value) => - bridge.callbackRequest("writeCoreStorage", [key, value]) as ReturnType, + bridge.callbackRequest("writeCoreStorage", [key, value]) as ReturnType< + RawCallbacks["writeCoreStorage"] + >, clearCoreStorage: (key) => - bridge.callbackRequest("clearCoreStorage", [key]) as ReturnType, + bridge.callbackRequest("clearCoreStorage", [key]) as ReturnType< + RawCallbacks["clearCoreStorage"] + >, featureSupported: (request) => - bridge.callbackRequest("featureSupported", [request]) as ReturnType, + bridge.callbackRequest("featureSupported", [request]) as ReturnType< + RawCallbacks["featureSupported"] + >, navigateTo: (url) => - bridge.callbackRequest("navigateTo", [url]) as ReturnType, + bridge.callbackRequest("navigateTo", [url]) as ReturnType< + RawCallbacks["navigateTo"] + >, pushNotification: (notification) => - bridge.callbackRequest("pushNotification", [notification]) as ReturnType, + bridge.callbackRequest("pushNotification", [notification]) as ReturnType< + RawCallbacks["pushNotification"] + >, cancelNotification: (id) => - bridge.callbackRequest("cancelNotification", [id]) as ReturnType, + bridge.callbackRequest("cancelNotification", [id]) as ReturnType< + RawCallbacks["cancelNotification"] + >, devicePermission: (request) => - bridge.callbackRequest("devicePermission", [request]) as ReturnType, + bridge.callbackRequest("devicePermission", [request]) as ReturnType< + RawCallbacks["devicePermission"] + >, remotePermission: (request) => - bridge.callbackRequest("remotePermission", [request]) as ReturnType, + bridge.callbackRequest("remotePermission", [request]) as ReturnType< + RawCallbacks["remotePermission"] + >, submitPreimage: (value, bulletinAllowanceSigner) => - bridge.callbackRequest("submitPreimage", [value, bridge.registerBulletinAllowanceSigner(bulletinAllowanceSigner)]) as ReturnType, + bridge.callbackRequest("submitPreimage", [ + value, + bridge.registerBulletinAllowanceSigner(bulletinAllowanceSigner), + ]) as ReturnType, read: (key) => bridge.callbackRequest("read", [key]) as ReturnType, write: (key, value) => - bridge.callbackRequest("write", [key, value]) as ReturnType, + bridge.callbackRequest("write", [key, value]) as ReturnType< + RawCallbacks["write"] + >, clear: (key) => - bridge.callbackRequest("clear", [key]) as ReturnType, + bridge.callbackRequest("clear", [key]) as ReturnType< + RawCallbacks["clear"] + >, confirmUserAction: (review) => - bridge.callbackRequest("confirmUserAction", [review]) as ReturnType, + bridge.callbackRequest("confirmUserAction", [review]) as ReturnType< + RawCallbacks["confirmUserAction"] + >, }; } -function subscriptionRawCallbacks(bridge: WorkerCallbackBridge): Required> { +function subscriptionRawCallbacks( + bridge: WorkerCallbackBridge, +): Required> { return { lookupPreimage: (key, sendItem, sendError) => bridge.startSubscription("lookupPreimage", key, sendItem, sendError), diff --git a/rust/crates/truapi-codegen/tests/golden_rust_emit.rs b/rust/crates/truapi-codegen/tests/golden_rust_emit.rs index bd650d13..50465b2b 100644 --- a/rust/crates/truapi-codegen/tests/golden_rust_emit.rs +++ b/rust/crates/truapi-codegen/tests/golden_rust_emit.rs @@ -19,16 +19,30 @@ fn quoted_strings_in_const_array(src: &str, const_name: &str) -> Vec { let end = rest .find("] as const") .unwrap_or_else(|| panic!("unterminated {const_name}")); - rest[..end] - .lines() - .filter_map(|line| { - let trimmed = line.trim().trim_end_matches(','); - trimmed - .strip_prefix('"') - .and_then(|s| s.strip_suffix('"')) - .map(str::to_string) - }) - .collect() + let body = &rest[..end]; + let mut strings = Vec::new(); + let mut chars = body.chars(); + while let Some(ch) = chars.next() { + if ch != '"' { + continue; + } + let mut value = String::new(); + let mut escaped = false; + for ch in chars.by_ref() { + if escaped { + value.push(ch); + escaped = false; + } else if ch == '\\' { + escaped = true; + } else if ch == '"' { + break; + } else { + value.push(ch); + } + } + strings.push(value); + } + strings } /// Run `cargo +nightly rustdoc -p truapi --output-format json` into the @@ -79,6 +93,61 @@ fn workspace_root() -> PathBuf { .to_path_buf() } +fn rustfmt_generated(files: &[PathBuf]) { + if files.is_empty() { + return; + } + + let mut command = Command::new("rustfmt"); + command.args(["+nightly", "--edition", "2024"]); + for file in files { + command.arg(file); + } + let output = command + .output() + .expect("failed to spawn rustfmt; install nightly rustfmt via rustup"); + assert!( + output.status.success(), + "rustfmt failed (status {}).\nstdout:\n{}\nstderr:\n{}", + output.status, + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ); +} + +fn prettier_generated(workspace_root: &Path, files: &[PathBuf]) { + if files.is_empty() { + return; + } + + let mut command = Command::new("npm"); + command + .args([ + "exec", + "--yes", + "--package=prettier@3.8.3", + "--", + "prettier", + "--write", + "--config", + ]) + .arg(workspace_root.join(".prettierrc")); + for file in files { + command.arg(file); + } + let output = command + .current_dir(workspace_root) + .output() + .expect("failed to spawn `npm exec -- prettier`; run `npm ci` first"); + assert!( + output.status.success(), + "prettier failed (status {}).\nstdout:\n{}\nstderr:\n{}", + output.status, + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ); +} + #[test] fn golden_dispatcher_and_wire_table() { let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); @@ -204,6 +273,15 @@ fn golden_host_callbacks_ts() { String::from_utf8_lossy(&out.stdout), String::from_utf8_lossy(&out.stderr), ); + prettier_generated( + &workspace, + &[ + tempdir.path().join("host/host-callbacks.ts"), + tempdir.path().join("wasm/host-callbacks-adapter.ts"), + tempdir.path().join("wasm/worker-callbacks.ts"), + ], + ); + rustfmt_generated(&[tempdir.path().join("rust-wasm/generated_bridge.rs")]); let golden_path = manifest_dir.join("tests/golden/host-callbacks.ts"); let golden = diff --git a/rust/crates/truapi-server/Cargo.toml b/rust/crates/truapi-server/Cargo.toml index 93ce86b5..4a2e9e72 100644 --- a/rust/crates/truapi-server/Cargo.toml +++ b/rust/crates/truapi-server/Cargo.toml @@ -8,6 +8,23 @@ license = "MIT" [lib] crate-type = ["rlib", "cdylib"] +[package.metadata.wasm-pack.profile.release] +wasm-opt = [ + "-Oz", + "--enable-bulk-memory", + "--enable-nontrapping-float-to-int", + "--enable-reference-types", + "--enable-sign-ext", + "--strip-debug", + "--strip-dwarf", + "--strip-producers", +] + +[package.metadata.wasm-pack.profile.release.wasm-bindgen] +debug-js-glue = false +demangle-name-section = false +dwarf-debug-info = false + [features] default = [] diff --git a/rust/crates/truapi/src/api/account.rs b/rust/crates/truapi/src/api/account.rs index 83211328..1ccaac15 100644 --- a/rust/crates/truapi/src/api/account.rs +++ b/rust/crates/truapi/src/api/account.rs @@ -43,6 +43,15 @@ pub trait Account: Send + Sync { /// }); /// assert(result.isOk(), "getAccount failed:", result); /// console.log("account retrieved:", result.value); + /// + /// const otherProduct = await truapi.account.getAccount({ + /// productAccountId: { + /// dotNsIdentifier: "other-product.dot", + /// derivationIndex: 0, + /// }, + /// }); + /// assert(otherProduct.isOk(), "cross-product getAccount was denied or failed:", otherProduct); + /// console.log("other product account retrieved after approval:", otherProduct.value); /// ``` #[wire(request_id = 22)] async fn get_account( diff --git a/rust/crates/truapi/src/api/statement_store.rs b/rust/crates/truapi/src/api/statement_store.rs index 16cfc8cd..8a3a5f2b 100644 --- a/rust/crates/truapi/src/api/statement_store.rs +++ b/rust/crates/truapi/src/api/statement_store.rs @@ -25,14 +25,8 @@ pub trait StatementStore: Send + Sync { /// const expiry = BigInt(Math.floor(Date.now() / 1000) + 86400) << 32n; /// const statement: Statement = { expiry, topics: [topic] }; /// - /// const proofResult = await truapi.statementStore.createProof({ - /// productAccountId: { - /// dotNsIdentifier: "truapi-playground.dot", - /// derivationIndex: 0, - /// }, - /// statement, - /// }); - /// assert(proofResult.isOk(), "createProof failed:", proofResult); + /// const proofResult = await truapi.statementStore.createProofAuthorized(statement); + /// assert(proofResult.isOk(), "createProofAuthorized failed:", proofResult); /// /// statement.proof = proofResult.value.proof; /// console.log("submitting statement:", statement); @@ -68,7 +62,8 @@ pub trait StatementStore: Send + Sync { /// /// **Deprecated:** use [`create_proof_authorized`](Self::create_proof_authorized) /// instead, which uses a pre-allocated allowance account and does not - /// require a per-call signing prompt. + /// require a per-call signing prompt. Pairing hosts may reject this method + /// when their signing channel cannot sign statement proof payloads exactly. /// /// ```ts /// // Expiry packs a Unix-seconds timestamp in the high 32 bits; a day out @@ -84,8 +79,11 @@ pub trait StatementStore: Send + Sync { /// }, /// statement, /// }); - /// assert(result.isOk(), "createProof failed:", result); - /// console.log("proof created:", result.value); + /// if (result.isErr()) { + /// console.log("deprecated createProof unavailable:", result.error); + /// } else { + /// console.log("proof created:", result.value); + /// } /// ``` #[wire(request_id = 60)] async fn create_proof( @@ -136,14 +134,8 @@ pub trait StatementStore: Send + Sync { /// const expiry = BigInt(Math.floor(Date.now() / 1000) + 86400) << 32n; /// const statement = { expiry, topics: [topic] }; /// - /// const proofResult = await truapi.statementStore.createProof({ - /// productAccountId: { - /// dotNsIdentifier: "truapi-playground.dot", - /// derivationIndex: 0, - /// }, - /// statement, - /// }); - /// assert(proofResult.isOk(), "createProof failed:", proofResult); + /// const proofResult = await truapi.statementStore.createProofAuthorized(statement); + /// assert(proofResult.isOk(), "createProofAuthorized failed:", proofResult); /// /// const result = await truapi.statementStore.submit({ /// proof: proofResult.value.proof,