Skip to content

feat(host-cli): @dotli/host-cli, a terminal host for the TrUAPI core - #133

Draft
ReinhardHatko wants to merge 1 commit into
mainfrom
feat/host-cli
Draft

feat(host-cli): @dotli/host-cli, a terminal host for the TrUAPI core#133
ReinhardHatko wants to merge 1 commit into
mainfrom
feat/host-cli

Conversation

@ReinhardHatko

Copy link
Copy Markdown

What this is

dotli's v0.7.0 switch to the Rust core (#70, #77) quietly produced something
bigger than a web refactor: a complete, headless host engine. The core owns
the wire dispatcher, SCALE, accounts, signing, statement store, SSO pairing,
restore, and logout. A platform supplies 17 callbacks and a human surface.
The web app is one such platform. A terminal is another, and real terminal
products exist today with no supported way to be hosted.

This branch adds packages/host-cli: the terminal peer of the web host,
published to npm as dotli's first consumable library. Nothing in the web
app, its build, or its release process changes.

                @parity/truapi-host   (Rust -> WASM host engine)
       wire dispatcher, SCALE, accounts, signing, statement store,
                 SSO pairing, restore, logout
                    ▲                            ▲
     browser impls  │                            │  terminal impls
                    │                            │
   dotli WEB host (packages/ui)         @dotli/host-cli (this branch)
   bridge.ts, host-callbacks/*          QR pairing, confirm prompts,
   modals, localStorage,                0600 file stores, pooled
   core in a Web Worker                 sockets, core IN-PROCESS
                    │                            │
        products in iframes             host and product share ONE
        (separate origins)              process over a loopback wire

A host is the thin layer around the engine that implements the
RequiredHostCallbacks surface and owns the user-facing presentation. That
second half is not optional: confirmUserAction(review) returns
Promise<boolean> and can only be answered by asking a human. A host that
cannot prompt can only auto-approve, which is a policy stub, not a host.
The web host answers with modals, localStorage, and a Web Worker. This one
answers with a QR code, readline prompts, owner-only files, and the core
running in-process via initSync. Same engine, different platform.

Why a terminal host, and why here

There is a concrete consumer: d3pot, a git remote for repositories stored
via the TrUAPI triangle (Asset Hub contracts, Bulletin storage, People
identity). Like any terminal product, it needs a host to pair with a wallet
and to sign. And it is not alone: playground-cli and bulletin-deploy are
further terminal products in the same position and are natural next
consumers. The only path any of them had, @parity/product-sdk-terminal,
is built on the deleted @novasamatech lineage and puts host internals in
a product SDK, where they do not belong. Without a supported host library,
every terminal product ends up hand-rolling the 17 callbacks and drifting
against each engine release.

Owning that library here is symmetric with the web: dotli's web host is not
in product-sdk either. It also means terminal products pair with the same
host identity conventions the web uses, so a user sees one coherent "dotli"
pairing story across surfaces.

The host is parameterized by the embedding app's metadata (host name, icon,
version, deeplink scheme, People/Bulletin genesis, served chains). For a
terminal app, the app IS the host. Nothing in the package is network- or
product-specific.

What the branch contains

One package, one workflow, one script change:

  • packages/host-cli (about 1,100 lines of source): the 17 typed callbacks
    built on the engine's own generated adapter (no hand-written SCALE), a
    swappable terminal presenter, owner-only (0600) JSON file stores, a chain
    connection pool, an in-process loopback wire, and two product-side
    helpers (serializeOperationStarts, explainProductError).
  • .github/workflows/release-host-cli.yml: tag-driven release
    (host-cli-vX.Y.Z) that gates on lint, typecheck, build, and tests,
    verifies the tag matches the package version, and publishes with npm
    provenance.
  • scripts/set-version.ts: now skips packages that are not private: true. App releases keep stamping the 15 private packages exactly as
    before.

Beyond the callbacks, the host owns behaviors that measurement (not
guesswork) showed the engine leaves to hosts:

  • The pairing deeplink arrives via AuthState.Pairing before any socket
    opens, so the QR renders instantly and offline.
  • Authenticating runs about 20 silent seconds (the People-chain statement
    round trip) with no callback in between. The presenter shows elapsed
    progress so the host does not look hung.
  • Core product-storage keys are scoped by product id, NOT by account. The
    host clears product storage on logout and when a different identity
    connects, so no identity inherits another's data.
  • The engine opens a chain socket per need (three People-chain sockets
    during one pairing). The pool shares sockets per genesis hash with
    per-lease request-id rewriting and strictly order-preserving delivery,
    capped at 2 leases per socket to stay under substrate's per-connection
    chainHead_v1_follow limit.
  • The theme and preimage callbacks are emit-once-then-stay-open
    streams. Returning early reads as end-of-stream to the engine.
  • A wallet that never answers produces a bare, untyped TxError after 180
    seconds. explainProductError turns it into actionable guidance.

Security stance, stated explicitly: confirm prompts are deliberately
modest. The host cannot decode callData or preimage content, and the
paired wallet is the authoritative surface that decodes before signing. So
prompts state the action kind and the typed metadata the host actually
knows, then defer to the phone. Prompts auto-deny on non-interactive stdin,
and there is no unattended signing path at all (the engine exposes no local
keypair API, a property treated here as a feature).

Why the web host and this package share no code (yet)

A fair first question for any reviewer: packages/host-cli reuses nothing
from packages/ui or the other workspace packages. That is deliberate, for
three reasons in decreasing order of weight.

  1. Everything a shared "host core" layer would traditionally hold already
    lives in the engine.
    Dispatch, sessions, signing, account derivation,
    storage scoping, and pairing are Rust code behind
    @parity/truapi-host. What remains on each side is the platform half:
    modals versus terminal prompts, localStorage versus 0600 files, a Web
    Worker versus in-process wasm. The two halves have no implementable
    middle. Both spokes therefore depend directly on the engine, and neither
    depends on the other.

  2. A published package cannot depend on private workspace packages.
    Every other @dotli package is private: true with raw ./src/*.ts
    exports. The chain pool is the concrete case: reusing the broker in
    @dotli/protocol was evaluated and rejected, because it would pull a
    private package into a published dependency graph. Real sharing would
    require publishing a host-common package too, multiplying the
    publishing obligation this PR asks the repo to take on exactly once.

  3. The overlap that does exist is mostly the engine's to own, and a
    shared dotli layer would hide that.
    Both hosts encode the same engine
    semantics today: product storage is keyed by product id and not by
    account, so both must clear it across identities. The wallet timeout
    surfaces as a bare untyped error both must translate. Confirmation
    reviews carry payloads no host can decode, so both must summarize
    modestly and defer to the wallet. Each of these is a candidate engine
    improvement (account-scoped storage, a typed timeout error, renderable
    reviews). Filing those upstream deletes the duplication. Centralizing it
    in a dotli package would merely relocate it and make the upstream gap
    harder to see.

The revisit trigger is concrete: when a second consumer inside this repo
needs the pooling, the network presets, or the presentation layer, extract
then, and let that consumer decide what crosses the published/private
boundary.

Evidence

All of it is re-runnable, and none of it is simulated:

  • The engine boots headless under plain node (no Worker, no DOM, no shims)
    and a real product client round-trips through it.
  • Pairing presentation works from a terminal: deeplink emitted in 17ms
    before any socket, full lifecycle observed live (Pairing, Authenticating,
    Connected), session restore in about 600ms without the phone.
  • The data-loss gate passed: the engine's entropy.derive output is
    byte-identical to the consumer's existing derivation across three
    contexts, so previously encrypted data stays readable.
  • A live end-to-end run drove the consumer's ENTIRE unmodified product
    stack through the engine from one terminal process and created real
    repositories via an Asset Hub contract, signed by the phone, owned by the
    product account that already existed. No ownership moved.
  • The package's own tests (30, run by turbo run test like every other
    package) boot the real wasm engine: a product localStorage round trip
    over the loopback, offline pairing presentation, logout clearing, plus
    unit coverage for the stores, the pool demultiplexer, and the ordering
    shim. The built dist was additionally smoke-tested under plain node, and
    npm publish --dry-run passes.

One measured hazard deserves every reviewer's attention. The chain-head
relay can deliver an operation's events before the start-response that
names its operationId. polkadot-api drops such events silently and the
read never settles (measured: hung 3 runs in 6, with a 155-request retry
storm, and inversion count correlated with the hang 6 out of 6). The
package ships serializeOperationStarts for the product side, which gave 6
clean runs out of 6. Browsers happen to win this race, which is why the web
app has never seen it. This is candidate upstream-report material for the
engine repo, now with numbers attached.

Costs, honestly

This is the first genuinely published dotli package, and publishing is the
main cost, not the code:

  1. Publishing machinery is a new, ongoing obligation. npm scope
    ownership for @dotli, an NPM_TOKEN secret, release discipline, and
    changelog upkeep. The workflow exists, but someone must own the process.
  2. A versioning policy carve-out. Private packages keep tracking app
    releases. Published packages version independently (host-cli starts at
    0.1.0) and release via their own tags. The rule is one line:
    app-versioned means private, published means independent. It is still a
    second regime where there used to be one.
  3. Repo shape. dotli is an app repo growing library-publishing habits.
    Consumer contracts, semver discipline, and API review are new muscles
    here.
  4. Rot risk. A terminal host cannot be exercised by Playwright, and
    code that only one downstream consumer runs rots silently. The
    mitigation ships with the package: its test suite boots the real wasm
    engine headless in CI rather than mocking it, so the hosting path is
    executed on every run.
  5. Reduced reuse. Non-dotli terminal vendors must depend on a dotli
    package or write their own host. Accepted as symmetric with the web.
  6. Node-only dependencies (qrcode, readline usage) now live in a
    browser-focused monorepo. They are isolated to this one package, which
    also deviates (documented, deliberately) to a NodeNext tsconfig because
    it ships built ESM for node.
  7. Two known seams, both documented in the package README. The engine
    does not export its typed-to-raw adapter, so the package reaches it by
    file URL (upstream packaging ask). And product-sdk consumers currently
    reach any embedded host through a test-only injection point
    (setTruApiClient). The consumer integration is defining what the
    supported seam should look like before that ask is filed.

Alternatives considered

  • Keep hosts in product-sdk. Rejected on the layer boundary: a product
    SDK must not ship a host, and today's product-sdk-terminal/host export
    is exactly the inversion being removed.
  • Each terminal app hand-rolls its host. The status quo counterfactual:
    raw SCALE callback dispatch per app, drifting against every engine
    release, with every app re-learning the hazards listed above one outage
    at a time.
  • Vendor the host into the consumer. Copied code survives only while it
    is frozen. A live, evolving host vendored beside a live web host is
    precisely where drift happens, and every vendored copy is one engine
    release away from diverging silently.
  • A standalone repo for the terminal host. The strongest alternative,
    and the honest fallback if maintainers do not want the publishing
    obligation here. Costs: a third place where host conventions live, and
    the web/terminal pairing identities drift apart. If the publishing burden
    is the blocker, this is the escape hatch to discuss.

What this asks of maintainers

  1. Review the branch (one commit: the package, the release workflow, the
    set-version.ts carve-out). It was built against the repo's
    CONTRIBUTING rules, including the test-story format.
  2. Decide on the @dotli npm scope and provide NPM_TOKEN if accepted.
  3. Bless (or amend) the versioning rule: private packages app-versioned,
    published packages independent.
  4. Agree who owns host-cli releases going forward.

One repo-level note discovered while wiring provenance: the root
package.json still declares repository: paritytech/dotli, which npm
provenance rejects. The new package pins dotli-community for itself, but
the root field deserves its own fix.

🤖 Generated with Claude Code

@dotli/host-cli 0.1.0, dotli's first genuinely published package and the
terminal PEER of the web host: both depend directly on @parity/truapi-host
and implement its 17 platform callbacks; neither depends on the other.
Built on origin/main (the v0.7.0 Rust-core lineage) — feat/host-core is
stranded reference material, deliberately left untouched.

The host is parameterized by the embedding app's metadata (host
{name,icon,version}, pairing deeplink scheme, people/bulletin genesis,
chain endpoints): a CLI app is its own host. The typed callback surface
comes from the package's own generated adapter, reached by file URL
because createWasmRawCallbacks is not in the exports map (candidate
upstream ask; hand-written SCALE callbacks are the drift this avoids).

Beyond the callbacks, the host owns what measurement showed the core
leaves to it:
- pairing QR rendered offline from AuthState.Pairing, and an elapsed
  progress line through the silent ~20s Authenticating window;
- product storage cleared on logout AND when a different identity
  connects — core product-storage keys carry no account component, so
  the next identity would inherit the previous one's data;
- chain connections pooled by genesis hash (the core opens one socket
  per need) with per-lease request-id rewriting, subscription-token
  routing, order-preserving delivery, and a lease cap per socket below
  substrate's per-connection chainHead follow limit;
- theme/preimage streams emit once and park (returning reads as
  end-of-stream to the core);
- the untyped 180s SSO timeout translated into phone-facing guidance
  (explainProductError), with logLevel defaulting to warn because the
  core's own diagnosis is only a tracing warning;
- serializeOperationStarts exported for the product side: papi over the
  core hung 3/6 without it and ran 6/6 clean with it.

confirmUserAction prompts are deliberately modest: the host cannot
decode callData or preimage content, so prompts state the review kind
and the typed metadata the host actually knows, and defer content
verification to the paired wallet — the authoritative trust surface.
Non-TTY prompts auto-deny; there is no unattended signing path at all.

Publishing machinery, per the decided policy:
- private:false, tsc build to dist/ (ESM + d.ts), exports at built JS,
  files limited to dist+docs, LICENSE included, npm publish dry-run
  verified;
- independent semver starting at 0.1.0: scripts/set-version.ts now
  skips published (non-private) packages, so app releases keep syncing
  the 15 private packages without touching this one;
- release via host-cli-v* tags: .github/workflows/release-host-cli.yml
  gates on lint/typecheck/build/test, verifies tag==package version,
  publishes with npm provenance (needs the NPM_TOKEN secret).

Tests run the REAL wasm core headless: a product localStorage
round-trip over the loopback wire, the pairing presentation offline
(deeplink emitted with no sockets), logout clearing, plus unit coverage
for the kv store (0600, write races), the chain pool demux, and the
ordering shim. The built dist was additionally smoke-tested under plain
node.

A two-axis review (standards + spec sub-agents) ran before this commit
and its findings are folded in: repository.url names dotli-community
(npm provenance would have hard-failed on the paritytech/dotli
mismatch), loadWasmCore memoizes per dist directory instead of one
process-wide singleton, comments and prompts follow CONTRIBUTING's
documentation rules, and every test is a user story structured
Given/When/Then.

Known seam, documented in the README: product-sdk consumers reach this
host through @parity/product-sdk-host/testing's setTruApiClient — a
test-only entry point, used knowingly until a real consumer shapes the
supported injection point (decided 2026-07-29 not to file that ask yet).
@socket-security

Copy link
Copy Markdown

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Added@​types/​node@​22.20.11001008196100

View full report

@github-actions

Copy link
Copy Markdown
Contributor

Bundle Size Report

Chunks over 500 KB:

File Raw Brotli Gzip
host/assets/paseo.smol-DboPaEh1.json 1.84 MB 941.7 KB 1019.4 KB
host/assets/paseo-people-next.smol.json 3.36 MB 1.68 MB 1.82 MB
host/assets/previewnet.smol.json 1.88 MB 181.4 KB 353.0 KB
host/assets/smoldot.js 2.98 MB 2.22 MB (+228 B) 2.23 MB
host/assets/smoldot_worker.js 2.95 MB 2.21 MB 2.22 MB
host/assets/wasm/web/truapi_server_bg.wasm 1.97 MB 623.3 KB 818.7 KB
Total 15.96 MB (+5.8 KB) 8.13 MB (+1.5 KB) (-49%) 8.76 MB (+1.6 KB)
All files
File Raw Brotli Gzip
host/.well-known/apple-app-site-association 738 B 738 B 738 B
host/.well-known/assetlinks.json 1.3 KB 317 B 391 B
host/assets/bridge.js 68.9 KB 18.8 KB (+8 B) 21.6 KB (-1 B)
host/assets/browser.js 22.9 KB 7.5 KB (-17 B) 8.6 KB (+2 B)
host/assets/client.js 100.1 KB 29.4 KB (+6 B) 32.4 KB (+2 B)
host/assets/dist.js 39.0 KB 12.8 KB (-43 B) 14.6 KB
host/assets/dotli-debug-bus.js 710 B 710 B 710 B
host/assets/get-sync-provider.js 2.8 KB 1.1 KB (-1 B) 1.2 KB (+1 B)
host/assets/hex.js 154 B 154 B 154 B
host/assets/index.js 184.4 KB (-416 B) 46.4 KB (-150 B) 55.4 KB (-144 B)
host/assets/index.css 45.2 KB 7.1 KB 7.9 KB
host/assets/manifest.js 22.5 KB 7.2 KB (-9 B) 7.9 KB (+2 B)
host/assets/panel.js 72.9 KB 19.8 KB (+16 B) 22.4 KB (+1 B)
host/assets/paseo.smol-DboPaEh1.json 1.84 MB 941.7 KB 1019.4 KB
host/assets/paseo-people-next.smol.json 3.36 MB 1.68 MB 1.82 MB
host/assets/paseo.smol.json 25.4 KB 4.9 KB 5.6 KB
host/assets/previewnet.smol.json 1.88 MB 181.4 KB 353.0 KB
host/assets/resolve.js 128 B 128 B 128 B
host/assets/rpc-resolve.js 2.4 KB 1.0 KB (-6 B) 1.1 KB (+1 B)
host/assets/shared-mode.js 1.8 KB 745 B (+2 B) 845 B
host/assets/smoldot.js 2.98 MB 2.22 MB (+228 B) 2.23 MB
host/assets/smoldot_worker.js 2.95 MB 2.21 MB 2.22 MB
host/assets/src.js 1.8 KB 848 B (-7 B) 946 B (+2 B)
host/assets/styles.css 15.1 KB 3.2 KB 3.8 KB
host/assets/wasm/web/README.md 10.9 KB 10.9 KB 10.9 KB
host/assets/wasm/web/package.json 371 B 371 B 371 B
host/assets/wasm/web/truapi_server.d.ts 6.9 KB 6.9 KB 6.9 KB
host/assets/wasm/web/truapi_server.js 35.6 KB 6.3 KB 7.2 KB
host/assets/wasm/web/truapi_server_bg.wasm 1.97 MB 623.3 KB 818.7 KB
host/assets/wasm/web/truapi_server_bg.wasm.d.ts 2.5 KB 2.5 KB 2.5 KB
host/assets/web.js 13.2 KB 3.5 KB (-2 B) 4.0 KB (-1 B)
host/assets/worker-runtime.js 6.3 KB (+6.2 KB) 1.6 KB (+1.5 KB) 1.8 KB (+1.7 KB)
host/assets/worker-runtime.js 106 B 106 B 106 B
host/assets/ws.js 23.1 KB 7.5 KB (+4 B) 8.2 KB (+3 B)
host/dotli.png 11.5 KB 11.5 KB 11.5 KB
host/favicon.svg 1.8 KB 1.8 KB 1.8 KB
host/host-sw.js 2.7 KB 1.1 KB (-1 B) 1.2 KB (+4 B)
host/icon-192.png 12.5 KB 12.5 KB 12.5 KB
host/icon-512.png 42.8 KB 42.8 KB 42.8 KB
host/index.html 19.9 KB 4.4 KB 5.4 KB (+3 B)
host/manifest.webmanifest 441 B 441 B 441 B
host/workbox.js 14.8 KB 4.6 KB 5.1 KB
sandbox/app-sw.js 9.5 KB (-38 B) 3.1 KB (-16 B) 3.5 KB (-16 B)
sandbox/assets/bitswap-bridge.js 840 B 840 B 840 B
sandbox/assets/fetch.js 3.4 KB 1.2 KB 1.4 KB (-1 B)
sandbox/assets/index.js 118.0 KB 33.7 KB (-36 B) 39.6 KB (+1 B)
sandbox/assets/index.css 45.2 KB 7.1 KB 7.9 KB
sandbox/favicon.svg 1.8 KB 1.8 KB 1.8 KB
sandbox/index.html 1.7 KB 583 B (+2 B) 788 B (+1 B)
Total 15.96 MB (+5.8 KB) 8.13 MB (+1.5 KB) (-49%) 8.76 MB (+1.6 KB)

Commit: e098671

@github-actions

Copy link
Copy Markdown
Contributor

⚡ Performance Report

⚠️ No baseline found on main. This PR's results are recorded but cannot be compared.
Merge to main to establish a baseline.

@github-actions

Copy link
Copy Markdown
Contributor

E2E Product suite failed on 8642250aee22ecd4e2a10599a95b94d035a70651 — 29 passed, 32 failed, 1 skipped.

Failed tests:

  • Product is ready
  • Get Product Account
  • Product Signer
  • Account Connection Status
  • Product Account Alias
  • Request Login
  • Get User Identity
  • Subscribe Theme
  • Derive Entropy
  • Well-Known Chains
  • StatementStore Allowance
  • Bulletin Allowance
  • Smart-Contract Allowance
  • All Allowances
  • String Write & Read
  • Bytes Write & Read
  • JSON Write & Read
  • Clear
  • Factory
  • Feature Check
  • Remote: HTTP/WS
  • Remote: WebRTC
  • Remote: Chain Submit
  • Remote: Preimage Submit
  • Remote: Statement Submit
  • As a product user, I can create an authorized statement proof
  • As a product user, I can submit a statement
  • Subscribe Match All
  • Subscribe Match Any
  • HTTP URL
  • Polkadot URL
  • As a product user, I can navigate within the current product

Logs: https://github.com/paritytech/dotli-community/actions/runs/30564631921
Artifacts: e2e-product-results (uploaded above) — open the failed test's trace.zip with npx playwright show-trace.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants