diff --git a/CLAUDE.md b/CLAUDE.md index f7624763..c7179573 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,17 +8,48 @@ 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) and via UniFFI (iOS/Android) + uniffi-bindgen-cli/ Thin CLI wrapper around uniffi::uniffi_bindgen_main() 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 host-side codegen + dispatcher (no shared core) + truapi-host-wasm/ @parity/truapi-host-wasm: WASM-backed host runtime. Subpath entries: + `.` (core Provider + dispatcher + node runtime), `/web` (iframe + Web + Worker), `/electron` (MessagePortMain), `/worker-runtime` (Worker entry). + WASM bundle (gitignored) under dist/wasm/{web,node}/, built via `make wasm` +android/ + truapi-host/ io.parity:truapi-host-android Maven library (AAR + UniFFI Kotlin bindings) +ios/ + truapi-host/ TrUAPIHost Swift Package (sources + UniFFI Swift bindings) +playground/ Next.js interactive playground; deploys to truapi-playground.dot +hosts/dotli/ dotli submodule +docs/ design docs, RFCs, feature proposals +scripts/codegen.sh regenerate the TS client from the Rust crate ``` +### Crate + binding invariants + +- `truapi` is canonical; runtime crates re-export rather than redefine. New + syscall traits and host-side runtime types live in `truapi-platform` and + `truapi-server`, not in `truapi`. Any additions to `truapi` itself are limited + to additive `Display` impls. +- All types exposed by `truapi-platform` and `truapi-server` come from + `truapi::versioned::*` and `truapi::v01::*`. The runtime crates re-export + rather than redefine. +- `truapi-server` WASM artifacts live under + `js/packages/truapi-host-wasm/dist/wasm/{web,node}/` 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. +- UniFFI bindings under `android/truapi-host/` and `ios/truapi-host/` are generated from the + `truapi-server` cdylib via `make uniffi`. The generated Swift modulemap may + need a one-time relocation into `Sources/truapi_serverFFI/include/`, the + `make uniffi` target prints a reminder. + ## Code style - Every `pub` Rust item (functions, methods, types, traits, modules, constants) carries a doc comment (`///` or `//!`). @@ -116,6 +147,87 @@ submodule init + `bun install` and the per-pane `cd` discipline). Alternatively, with a deployed Polkadot Desktop Host installed, navigate to `https://dot.li/localhost:3000` from within it. +#### Local dotli + playground E2E notes + +Use `make dev DEBUG=1` from the repo root for the local host stack. It prepares +the ignored WASM/build artifacts, verifies dotli can resolve +`@parity/truapi-host-wasm`, then starts dotli on `:5173` and the playground on +`:3000`. Open `http://localhost:5173/localhost:3000`. + +When automating with Playwright, block service workers for smoke tests unless +the test is explicitly about SW behavior. Stale host/product bundles can mask +runtime fixes. Use a fresh cache-busting query string on +`http://localhost:5173/localhost:3000?...`, collect `pageerror` and +`console` messages, and fail on unexpected page errors. + +For interactive SSO checks, prefer a persistent headed Chrome profile and reuse +the same browser context across checks. SSO pairing needs a real phone QR scan, +and signing/resource-allocation flows may need web or mobile confirmation; if +the human or companion app is unavailable, skip those methods and record the +skip instead of treating it as a protocol failure. Non-interactive checks should +still verify that the playground renders, the TrUAPI debug panel receives +host/product events, generated examples can call non-confirmation methods, and +logout/relogin does not restore a stale session. + +The dotli Playwright e2e suite under `hosts/dotli/apps/host/tests/e2e/` +pairs through the signer-bot service. It requires `SIGNER_BOT_SVC_TOKEN`; +`SIGNER_BOT_BASE_URL` and `SIGNER_BOT_NETWORK` default to dotli CI's +`https://signing-bot-dev.novasama-tech.org/` and `paseo-next-v2`. Without the +token, do not treat the full suite as locally runnable. Use +`E2E_DOTLI_SMOKE=1 make e2e-dotli` for the no-phone QR smoke path. + +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: + +```bash +localStorage.setItem("truapi:logLevel", "debug") +sessionStorage.setItem("dotli:truapi-debug", "1") +``` + +Reload after setting them. Watch for `Unknown wire discriminant`, missing +`@parity/truapi-host-wasm` imports, worker WASM instantiation failures, and +debug-panel traffic disappearing when the login popup opens. + ## Deployment Pushes to `main` trigger `.github/workflows/deploy-playground.yml`, which builds `playground/` and publishes the static export to `truapi-playground.dot` via `bulletin-deploy`. diff --git a/README.md b/README.md index a08d9483..78c09f9b 100644 --- a/README.md +++ b/README.md @@ -57,14 +57,47 @@ 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 + UniFFI surfaces + uniffi-bindgen-cli/ Thin CLI wrapper around uniffi::uniffi_bindgen_main() for the workspace 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 host-side codegen and dispatcher (no shared core) + truapi-host-wasm/ @parity/truapi-host-wasm: WASM-backed host runtime; entries `.` (core), + `/web` (iframe + Web Worker), `/electron` (MessagePortMain), `/worker-runtime` +android/ + truapi-host/ io.parity:truapi-host-android Maven library (AAR + UniFFI Kotlin bindings) +ios/ + truapi-host/ TrUAPIHost Swift Package (sources + UniFFI Swift bindings) +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 ``` +### Native + JS host SDKs + +JS hosts integrate the Rust core through [`@parity/truapi-host-wasm`](js/packages/truapi-host-wasm), +a single package with tree-shakeable subpath entries (the separate +`@parity/truapi-host`, with no shared core, is for hosts that bring their own runtime): + +- `@parity/truapi-host-wasm` (the `.` entry) ships the `truapi-server` WASM bundle, the + `Provider` factories that drive it, the dispatcher adapter, and `createNodeWasmProvider`. +- `@parity/truapi-host-wasm/web` wires the WASM provider into a browser host: the iframe + MessageChannel handshake (`createIframeHost`) plus `createWebWorkerProvider`. +- `@parity/truapi-host-wasm/electron` wraps an Electron `MessagePortMain` as a `Provider`. +- `@parity/truapi-host-wasm/worker-runtime` is the Web Worker entrypoint so the WASM core can + run off the page main thread. + +Native shells sit one level under `android/` and `ios/` and ship as versioned packages from git tags: + +- [`android/truapi-host/`](android/truapi-host) builds the `io.parity:truapi-host-android` Maven artifact (AAR + POM + sources jar). Distributed via JitPack as `com.github.paritytech.truapi:truapi-host:`. +- [`ios/truapi-host/`](ios/truapi-host) is a Swift Package consumed via `.package(url:)` or `.package(path:)`. + +The nested layout leaves room for additional packages alongside (e.g. `android/widgets/`, `ios/something-else/`) without re-shaping the top-level directories. + +Both link the `truapi-server` cdylib via UniFFI-generated bindings. The bindings are regenerated from the same Rust source via `make uniffi`. + ## 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 +113,11 @@ 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-* packages +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-wasm/dist/wasm/ +make uniffi # regenerate UniFFI Kotlin + Swift bindings under android/truapi-host/ and ios/truapi-host/ ``` To run the playground locally: @@ -129,4 +164,3 @@ See [`CONTRIBUTING.md`](CONTRIBUTING.md) for issue reports, feature proposals, a ## License [MIT](./LICENSE) - diff --git a/docs/design/dotli-architecture-change.md b/docs/design/dotli-architecture-change.md new file mode 100644 index 00000000..9a58f4d8 --- /dev/null +++ b/docs/design/dotli-architecture-change.md @@ -0,0 +1,300 @@ +# Dotli architecture change, visual reference + +Companion to [dotli-rust-core-proposal.md](./dotli-rust-core-proposal.md). Shown as diagrams plus a deep dive on how the host-callback surface maps to the shared-core SDK vision. + +The point of these diagrams: justify what is **in scope** for the dotli migration diff and what is explicitly **deferred**. The migration replaces the novasamatech/host-api stack with the TrUAPI Rust core; nothing else. + +--- + +## 1. Where protocol logic lives (the headline change) + +``` + BEFORE (origin/main) + ┌─────────────────────────────────────────────────────────────┐ + │ Product iframe (sandbox) │ + │ @novasamatech/host-papp ─── product client │ + └──────────────────────┬──────────────────────────────────────┘ + │ postMessage (host-container wire) + ▼ + ┌─────────────────────────────────────────────────────────────┐ + │ dot.li main thread │ + │ ┌─────────────────────────────────────────────────────────┐ │ + │ │ container.ts + statement-store-mapping.ts │ │ + │ │ ─────────────────────────────────────────────────────── │ │ + │ │ routing • codecs • subscriptions • permissions service │ │ + │ │ topic encoding • statement mapping • dotns parsing │ │ + │ │ rate limiting • feature flags • etc. │ │ + │ │ ALL OF THIS IS RE-IMPLEMENTED │ │ + │ │ ON iOS / Android / Electron TOO │ │ + │ └────────────────────────────┬────────────────────────────┘ │ + │ │ │ + │ OS primitives (modals, localStorage, │ + │ smoldot, host-papp, fetch, Notification API) │ + └─────────────────────────────────────────────────────────────┘ + + + AFTER (this refactor) + ┌─────────────────────────────────────────────────────────────┐ + │ Product iframe ── origin: .app.dot.li ── (per-CID) │ + │ @parity/truapi (codegen) ─── product client │ + └──────────────────────┬──────────────────────────────────────┘ + │ MessageChannel (TrUAPI wire bytes) + │ port handed off via the host shell + │ during the `truapi-init` handshake + ▼ + ┌─────────────────────────────────────────────────────────────┐ + │ Host shell ── origin: dot.li ── (user-visible UI) │ + │ - top bar, modal prompts │ + │ - creates the protocol iframe + product iframe │ + │ - relays MessagePorts between them (no protocol logic) │ + └──────────────────────┬──────────────────────────────────────┘ + │ MessageChannel port (transferred) + ▼ + ┌─────────────────────────────────────────────────────────────┐ + │ Protocol iframe ── origin: host.dot.li ── (STABLE origin) │ + │ (hidden iframe embedded by every dot.li tab) │ + │ │ + │ thin JS shim: │ + │ - constructs the SharedWorker below │ + │ - exposes platform callbacks the WASM core can't make │ + │ directly from a worker (modal UI prompts are routed │ + │ back through the host shell at dot.li) │ + │ - migrates legacy localStorage sessions into IndexedDB │ + │ │ + │ ┌────────── SharedWorker (host.dot.li) ─────────┐ │ + │ │ │ │ + │ │ truapi-server (Rust → WASM) │ │ + │ │ ────────────────────────── │ │ + │ │ routing • SCALE codecs • subscriptions │ │ + │ │ permissions service │ │ + │ │ statement mapping │ │ + │ │ dotns parsing • rate limit │ │ + │ │ embedded smoldot ── chain provider │ │ + │ │ session state │ │ + │ │ │ │ + │ │ storage: IndexedDB on host.dot.li │ │ + │ │ (stable across product CID changes; shared │ │ + │ │ across every tab via SharedWorker semantics)│ │ + │ └───────────────────────────────────────────────┘ │ + └─────────────────────────────────────────────────────────────┘ + + Same logic, written once in Rust, shared across iOS / Android / web. +``` + +### Origin model, why host.dot.li + +Production nginx routes (see `dotli/nginx/nginx.polkadot`): + +| Hostname | Build | Role | +|---------------------------|-------------|-------------------------------------------------------------------| +| `dot.li` and `*.dot.li` | host | Main shell, user-visible UI, top bar, dApp loader | +| `.app.dot.li` | sandbox | Product iframes, origin changes every CID update | +| `host.dot.li` | protocol | Stable-origin protocol iframe, hidden, embedded by every tab | + +Product iframes can't host the protocol core: their origin changes with every app CID, so any `localStorage` / IndexedDB / OPFS state would be lost on every update. The host shell at `dot.li` is stable but cohabits with user-facing UI; running heavy crypto + smoldot there would block paint frames. + +The protocol iframe at `host.dot.li` has neither problem: it's a stable origin and it has no UI, so a same-origin worker constructed from it runs the WASM core off the main thread while keeping `truapi`'s persistent state on a stable origin. + +> The shipped `@parity/truapi-host-wasm/worker-runtime` entrypoint is a plain per-tab dedicated Web Worker. The `SharedWorker` topology drawn in these diagrams is the recommended target (see Option 2 in the companion proposal), not yet implemented. Read every `SharedWorker` mention below as the future shape. + +`SharedWorker` semantics give two further wins: + +- **One core per browser, not per tab.** Session state, permission grants, and chain connections are implicit cross-tab state. Replaces the existing `BroadcastChannel` glue for shared auth. +- **Embedded smoldot.** Since the SharedWorker is already the single per-origin core, smoldot lives inside it. Dotli's separate `protocol-shared-worker.ts` smoldot SharedWorker collapses into this one. + +`SharedWorker` does not expose `localStorage` (main-thread only). The `truapi-platform::Storage` impl persists to **IndexedDB** on the `host.dot.li` origin. The thin JS shim in the protocol iframe runs a one-time migration of the existing `PAPP_${siteId}_*` localStorage keys into IDB so sessions survive the cutover. + +--- + +## 2. Module-level diff in `@dotli/ui` + +``` + ──── DELETED ──── ──── NEW ───────────────────────── + + container.ts 930 LOC host-callbacks/ + statement-store-mapping 170 LOC ├─ Account.ts host-papp + ├─ Chain.ts smoldot/RPC + ├─ LocalStorage.ts localStorage + ──── DEPS DROPPED ─── ├─ OpenUrl.ts window.open + ├─ Preimage.ts Helia (IPFS) + @novasamatech/host-api ├─ PromptPermission.ts modal + @novasamatech/host-container ├─ PushNotification.ts Notification + @novasamatech/sdk-statement ├─ Signing.ts host-papp + @novasamatech/statement-store ├─ StatementStore.ts sub-store + └─ handlers.ts glue + + ──── DEPS ADDED ──── + + @parity/truapi-host-wasm SharedWorker entrypoint that imports + the WASM core (smoldot embedded) + @parity/truapi-host-wasm/web protocol-iframe shim: constructs the + SharedWorker, exposes the platform + callbacks the worker can't make from + its own context (modal UI, etc.), + relays MessagePort handoffs from the + host shell at dot.li + @parity/truapi types from codegen + + ──── KEPT ────────── + + bridge.ts rewritten: ~80 LOC, was ~120 LOC + permissions.ts kept (per-dApp grant storage) + permission-modal.ts kept (UI primitive) + render.ts kept (no-op for non-iframe content) + topbar.ts kept (UI) + @novasamatech/host-papp kept (account/signing; retired in D1) +``` + +--- + +## 3. The shrinking host-callback surface + +``` + BEFORE AFTER + (15+ handlers, (6 host-facing + JS owns the logic) capability traits + the core can't + make itself) + + ┌──────────────────────────┐ ┌──────────────────────────┐ + │ accountGet │ ──────► │ accountGet (D1*) │ + │ accountGetAlias │ ──────► │ accountGetAlias (D1*) │ + │ getNonProductAccounts │ ──────► │ getNonProduct… (D1*) │ + │ getUserId │ ──────► │ getUserId (D1*) │ + │ accountConnectionStatus… │ ──────► │ accountConn… (D1*) │ + │ signPayload │ ──────► │ signPayload (D1*) │ + │ signRaw │ ──────► │ signRaw (D1*) │ + │ statementStoreSubmit │ ──────► │ statementStore… (D2*) │ + │ statementStoreSubscribe │ ──────► │ statementStore… (D2*) │ + │ statementStoreCreateProof│ ──────► │ statementStore… (D2*) │ + │ preimageLookupSubscribe │ ──────► │ preimageLookup… (D2*) │ + │ │ ├──────────────────────────┤ + │ devicePermission ────────┼─────► │ devicePermission │ + │ remotePermission ────────┼─────► │ remotePermission │ + │ navigateTo (parsing) ────┼─────► │ navigateTo (parsed core) │ + │ featureSupported │ ──────► │ featureSupported │ + │ localStorage* │ ──────► │ Storage read/write/clear │ + │ pushNotification │ ──────► │ pushNotification │ + │ chainConnection │ ──────► │ chainConnect (E1*) │ + │ themeSubscribe (#366) │ ──X │ (out of scope) │ + └──────────────────────────┘ └──────────────────────────┘ + + * (D1/D2/E1) = host-papp, libp2p, layer-2 retirement issues already + documented in tracking issues, NOT this PR. + The AFTER column names match the `truapi-platform` traits as shipped: + `Permissions` keeps the two-call device/remote split per v0.1, + `Navigation` receives URLs already parsed by the core, `Features`, + `Storage`, `Notifications`, and `ChainProvider` cover the rest. +``` + +--- + +## 4. Mapping host callbacks to `truapi-platform` traits + +Every row in the middle block of diagram §3 is one piece of "logic that used to live in JS, now lives in Rust." Each maps to one of the **capability traits** the host implements in `truapi-platform` (`Storage`, `Navigation`, `Notifications`, `Permissions`, `Features`, `ChainProvider`); everything else gets pulled out of the host into the core. Where a section below describes a single consolidated prompt or a different trait vocabulary, it is calling out a **proposed future shape**, not the shipped surface; the shipped names are the six above. + +### 4.1 `devicePermission` + `remotePermission` (shipped split; consolidation proposed) + +**Before, host did the policy work.** Two separate callbacks: + +- `devicePermission(name)`, for browser-mediated permissions (camera, mic, geolocation, push). The JS host: + 1. Maintained the per-dApp grant cache in `localStorage`. + 2. Classified which device permissions were even *enforceable* in a browser iframe (notifications and `openUrl` are not really gateable from a sandboxed iframe; mic and camera are). + 3. Showed the consent modal, persisted the result, dispatched a "permission changed" event so the iframe could reload with the updated `allow` attribute. +- `remotePermission(req)`, for protocol-level permissions (`TransactionSubmit`, `StatementSubmit`, `ChainSubmit`, `WebRtc`, a wildcard `Remote` variant). The JS host: + 1. Mapped `TransactionSubmit` → user-friendly "Sign Transactions" label. + 2. Decided which `Remote` variants were gated vs. auto-granted. + 3. Showed a different modal flow (the now-deleted `showRemotePermissionModal`). + 4. Rate-limited. + +That is policy: classification, mapping, caching, rate limiting. By the test "why can't the Rust core do this directly?", none of it is a syscall. + +**Shipped today: the `Permissions` trait keeps the two-call split.** Per v0.1, `truapi-platform::Permissions` has `device_permission(HostDevicePermissionRequest)` and `remote_permission(RemotePermissionRequest)`, mirrored by the dotli adapter and the iOS/Android bridges. The host renders one modal flow per call and returns the response. + +**Proposed future shape: collapse both into one prompt.** A single host trait of the form `prompt_permission(HostPermission) -> bool` would let the Rust permissions service in core: + +- Know the canonical wire tags and their human labels. +- Check the cached decision (via `Storage::read`) before calling back. +- Decide whether the permission is enforceable; auto-grant the unenforceable ones without bothering the host. +- Rate-limit. +- Only when *the user must actually be asked*, dispatch the prompt and wait for the boolean. + +In dotli that consolidated callback would be a single `host-callbacks/PromptPermission.ts` whose sole job is to render the modal and return `true` on grant, `false` on deny, with the same trait implemented by Swift on iOS and Kotlin on Android. None of them would re-implement the cache, the rate limiter, or the wire-tag mapping. This consolidation is not yet in core; it is the direction this section argues for. + +The dotli adapter references `getPermissionStatus` / `setPermissionStatus` against a local `permissions.ts` store. Once the grant cache moves into the core's `Storage`, `permissions.ts` can disappear from the dotli tree entirely. + +### 4.2 `navigateTo (parsing)` → `Navigation::navigate_to (already parsed)` + +**Before, host was a URL parser.** `navigateTo(url)` handed a raw string to the host. JS had to: + +1. Detect a `.dot` deep link (`testingout.dot/some/path`) → drive the dotli internal router (DOTNS resolution, swap iframe contents, push history state). +2. Detect a normal `https://` URL → `window.open(url, "_blank")`. +3. Detect malformed input → reject. + +That is a parser plus a deep-link dispatcher. Three platforms, three parsers, three places to drift. + +**After, host owns one trait: "hand this URL to the OS browser."** Two distinct surfaces split in the core: + +- Internal routing (deep links to other `.dot` apps) is handled entirely inside the core. It dispatches itself, no host roundtrip. +- External navigation surfaces as `Navigation::navigate_to(url)`, and `url` is *already validated* by the core. The host treats it as opaque. + +In dotli, this is the host's `Navigation` impl, which is essentially `window.open(url, "_blank")`. The shipped `truapi-platform::Navigation` trait has the single `navigate_to` method; there is no separate deep-link callback because the core already knows the dApp graph and dispatches deep links directly. + +### 4.3 `featureSupported` (kept; planned for removal) + +`featureSupported(genesisHash)` lets the core ask "does this host know about this chain?" before letting a product call it. The host answers yes/no from its supported-chain catalog. + +The plan, tracked separately, is to drop this callback. The Rust core will bundle the chain catalog itself, so there is no question for the host to answer. That fits the "why can't the Rust core do this directly?" test, the answer for `featureSupported` is "it can," so the callback should not exist. + +### 4.4 `localStorage*` → `Storage::read` / `Storage::write` / `Storage::clear` + +**Before, implicit, scattered.** The novasamatech protocol had several scoped storage callbacks (one per dApp namespace), and the JS host computed prefixes (`dotli: