diff --git a/.github/workflows/release-host-cli.yml b/.github/workflows/release-host-cli.yml new file mode 100644 index 00000000..4e5cebdd --- /dev/null +++ b/.github/workflows/release-host-cli.yml @@ -0,0 +1,68 @@ +# Copyright 2026 Parity Technologies (UK) Ltd. +# SPDX-License-Identifier: AGPL-3.0-only + +# Publishes @dotli/host-cli to npm when its release tag is pushed: +# +# git tag host-cli-v0.1.0 && git push origin host-cli-v0.1.0 +# +# The package versions INDEPENDENTLY of the app (scripts/set-version.ts skips +# published packages), so its tags carry the package prefix. Publishing needs +# the NPM_TOKEN repository secret (npm automation token with publish rights on +# the @dotli scope). + +name: Release host-cli + +on: + push: + tags: + - "host-cli-v*" + +permissions: + contents: read + # npm --provenance signs the package against the workflow identity. + id-token: write + +jobs: + publish: + name: Build, test, publish + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version: "1.3.6" + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Verify the tag matches the package version + run: | + set -euo pipefail + TAG_VERSION="${GITHUB_REF_NAME#host-cli-v}" + PKG_VERSION="$(jq -r .version packages/host-cli/package.json)" + if [ "$TAG_VERSION" != "$PKG_VERSION" ]; then + echo "Tag says $TAG_VERSION but packages/host-cli/package.json says $PKG_VERSION" >&2 + exit 1 + fi + + - name: Lint + run: bunx --bun turbo run lint --filter=@dotli/host-cli + + - name: Type check + run: bunx --bun turbo run typecheck --filter=@dotli/host-cli + + - name: Build + run: bunx --bun turbo run build --filter=@dotli/host-cli + + - name: Test + run: bunx --bun turbo run test --filter=@dotli/host-cli + + - name: Publish to npm + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + run: | + set -euo pipefail + echo "//registry.npmjs.org/:_authToken=\${NODE_AUTH_TOKEN}" > "$HOME/.npmrc" + cd packages/host-cli + npm publish --access public --provenance diff --git a/bun.lock b/bun.lock index 9a42dc95..2820006e 100644 --- a/bun.lock +++ b/bun.lock @@ -1,6 +1,5 @@ { "lockfileVersion": 1, - "configVersion": 1, "workspaces": { "": { "name": "dotli", @@ -143,6 +142,25 @@ "typescript-eslint": "^8.61.0", }, }, + "packages/host-cli": { + "name": "@dotli/host-cli", + "version": "0.1.0", + "dependencies": { + "@parity/truapi-host": "0.2.1", + "neverthrow": "^8.2.0", + "qrcode": "^1.5.4", + }, + "devDependencies": { + "@dotli/eslint-config": "workspace:*", + "@dotli/typescript-config": "workspace:*", + "@parity/truapi": "0.5.1", + "@types/node": "^22.0.0", + "@types/qrcode": "^1.5.6", + "eslint": "^10.5.0", + "typescript": "~6.0.3", + "vitest": "^4.1.8", + }, + }, "packages/metrics": { "name": "@dotli/metrics", "version": "0.6.0", @@ -523,6 +541,8 @@ "@dotli/host": ["@dotli/host@workspace:apps/host"], + "@dotli/host-cli": ["@dotli/host-cli@workspace:packages/host-cli"], + "@dotli/metrics": ["@dotli/metrics@workspace:packages/metrics"], "@dotli/protocol": ["@dotli/protocol@workspace:packages/protocol"], @@ -1731,6 +1751,8 @@ "@commander-js/extra-typings/commander": ["commander@15.0.0", "", {}, "sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg=="], + "@dotli/host-cli/@types/node": ["@types/node@22.20.1", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q=="], + "@ensdomains/content-hash/multiformats": ["multiformats@12.1.3", "", {}, "sha512-eajQ/ZH7qXZQR2AgtfpmSMizQzmyYVmCql7pdhldPuYQi4atACekbJaQplk6dWyIi10jCaFnd6pqvcEFXjbaJw=="], "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], @@ -1799,6 +1821,8 @@ "@apideck/better-ajv-errors/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + "@dotli/host-cli/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + "@polkadot-api/cli/rollup/@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.62.2", "", { "os": "android", "cpu": "arm" }, "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg=="], "@polkadot-api/cli/rollup/@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.62.2", "", { "os": "android", "cpu": "arm64" }, "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw=="], diff --git a/packages/host-cli/CHANGELOG.md b/packages/host-cli/CHANGELOG.md new file mode 100644 index 00000000..387a4d1d --- /dev/null +++ b/packages/host-cli/CHANGELOG.md @@ -0,0 +1,30 @@ + + +# Changelog + +All notable changes to `@dotli/host-cli`. This package versions +independently of the dotli app (see README, "Versioning and releases"). + +## 0.1.0 (unreleased) + +Initial release: a terminal host for `@parity/truapi-host`. + +- The 17 typed platform callbacks, bridged through the package's own + generated adapter (no hand-written SCALE). +- In-process wasm boot (`initSync`) and an in-process loopback wire for + same-process products. +- Terminal presenter: pairing QR (offline, instant), progress through the + silent `Authenticating` window, deliberately modest confirm prompts that + defer content verification to the paired wallet, auto-deny on non-TTY. +- Owner-only (0600) JSON file storage for core and product state; product + storage cleared on logout and on identity change. +- Chain-connection pool keyed by genesis hash: per-lease request-id + rewriting, subscription routing, capped leases per socket, + order-preserving delivery. +- `serializeOperationStarts`: the load-bearing product-side shim for the + chain-head operation-ordering hazard. +- `explainProductError` / `isProbableSsoTimeout`: translate the untyped 180s + SSO timeout into actionable guidance. diff --git a/packages/host-cli/LICENSE b/packages/host-cli/LICENSE new file mode 100644 index 00000000..cba9f025 --- /dev/null +++ b/packages/host-cli/LICENSE @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + dot.li A trustless web browser that runs in your traditional browser. + Copyright (C) 2026 Parity Technologies + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/packages/host-cli/README.md b/packages/host-cli/README.md new file mode 100644 index 00000000..51d01be9 --- /dev/null +++ b/packages/host-cli/README.md @@ -0,0 +1,145 @@ + + +# @dotli/host-cli + +Terminal host for the TrUAPI Rust/WASM core +([`@parity/truapi-host`](https://www.npmjs.com/package/@parity/truapi-host)). + +A **host** is the platform half of a TrUAPI deployment: it implements the +core's 17 platform callbacks and owns the user-facing surface. dotli's web +host answers the core with modals, localStorage, and a Web Worker; this +package is its **terminal peer**: a QR in the terminal for pairing, readline +confirm prompts, owner-only (0600) file storage, pooled WebSocket chain +connections, and the core running in-process via `initSync`. Neither host +depends on the other; both depend directly on `@parity/truapi-host`. + +``` + @parity/truapi-host (Rust -> WASM host engine) + ▲ ▲ + browser impls │ │ terminal impls + dotli web host (packages/ui) @dotli/host-cli (this package) + modals, localStorage, Worker QR, prompts, files, in-process +``` + +An embedding CLI app **is its own host**: it supplies its identity +(`host: {name, icon, version}`), the wallet deeplink scheme, the +people/bulletin genesis hashes, and the chains it serves. Nothing here is +dotli- or network-specific. + +## Usage + +```ts +import { createCliHost } from "@dotli/host-cli"; +import { createClient, createTransport } from "@parity/truapi"; + +const host = await createCliHost({ + host: { name: "my-cli", version: "1.0.0" }, + pairing: { deeplinkScheme: "polkadotapp" }, + people: { genesisHash: "0x…" }, + bulletin: { genesisHash: "0x…" }, + chains: { "0x…": { name: "Asset Hub", rpc: "wss://…" } }, + storageDir: "~/.my-cli/truapi", +}); + +const product = host.createProduct({ productId: "my-app.dot" }); +const client = createClient(createTransport(product.provider)); + +// Drive login from the product side; the host renders the QR, the +// silent ~20s "Authenticating" window, and the session identity. +await client.account.requestLogin({ reason: "sign in to my-cli" }); +``` + +`examples/pair.ts` is the runnable version (network and phone required). + +### Wiring into `@parity/product-sdk-*`: via a TEST seam, knowingly + +A product stack built on `@parity/product-sdk-host` reaches this host through +`setTruApiClient` from `@parity/product-sdk-host/testing`: + +```ts +import { setTruApiClient } from "@parity/product-sdk-host/testing"; +setTruApiClient(createClient(createTransport(product.provider))); +``` + +That entry point is **documented as test-only** upstream ("silently reroutes +every host accessor"). It is the only injection point that exists today; this +package uses it knowingly, and the supported seam should be shaped by real +consumers like this one before it is proposed upstream. Track the caveat, do +not hide it. + +### papi consumers MUST wrap their provider + +If the product side drives chains through polkadot-api over the core's +host-mediated provider, wrap it: + +```ts +import { serializeOperationStarts } from "@dotli/host-cli"; +const provider = serializeOperationStarts(rawProviderOverTheCore); +``` + +The chain-head relay can deliver an operation's events **before** the +start-response that names its `operationId`; papi drops such events silently +and the read never settles (measured: hung 3 runs in 6, with a 155-request +retry storm; 6/6 clean with the shim). This is load-bearing, not defensive. + +## What the host owns beyond the 17 callbacks + +Each of these is a measured finding against the core, not a guess: + +- **Pairing presentation.** The deeplink arrives via `AuthState.Pairing` + before any socket opens, so the QR renders instantly and offline. +- **The silent `Authenticating` window.** The People-chain statement + round-trip runs ~20s with no callback in between; the presenter shows + elapsed progress so the host does not look hung. +- **Clearing product storage on logout.** Core product-storage keys are + scoped by product id, NOT by account, so the next identity would inherit the + previous one's data. `disconnectSession()` clears the store, and a + different identity connecting clears it as a crash-safe backstop. +- **Pooling chain connections.** The core opens a socket per need (three + People-chain sockets during pairing alone). The pool shares sockets per + genesis hash with per-lease id rewriting, capped leases per socket (default + 2, below substrate's per-connection `chainHead_v1_follow` limit), and + strictly order-preserving delivery. +- **Parked `theme`/`preimage` streams.** Both are emit-once-then-stay-open + subscriptions; returning early reads as end-of-stream to the core. +- **Translating the SSO timeout.** A wallet that never answers yields a bare + `TxError` after ~180s with no message; `explainProductError` turns it into + "no response from your phone" guidance. The core's own diagnosis is only a + `tracing` warning, which is why `logLevel` defaults to `warn`. + +## Security model: the phone is the trust surface + +`confirmUserAction` prompts are **deliberately modest**. The host cannot +decode what it approves: `CreateTransaction` carries opaque `callData`, +`PreimageSubmit` only a byte count. The paired wallet decodes and displays +the authoritative content before signing, so the terminal prompt states the +action kind, the typed metadata the host actually knows (account, chain, +sizes, the raw-bytes-vs-text discriminant of `SignRaw`), and defers the rest +to the phone. A prompt that pretended to summarize undecodable bytes would be +a false trust surface. + +Two consequences to accept knowingly: + +- **No unattended signing.** The core exposes no local keypair API; every + signature needs the paired phone. There is no CI path. +- **Non-interactive terminals deny.** A host that cannot ask must not + approve: prompts auto-deny when stdin is not a TTY. + +## Known seams (candidate upstream asks for `@parity/truapi-host`) + +- The typed-to-raw adapter (`createWasmRawCallbacks`) is not in the package's + exports map; it is reached by **file URL** relative to the exported wasm + path (`src/wasm.ts`). A `./node` export would remove this. +- The wasm is instantiated with `initSync` from bytes; the shipped Worker + runtime is browser-only by construction, not necessity. + +## Versioning and releases + +This package versions **independently** of the dotli app (semver, starting +0.1.0; breaking host-API changes bump minor pre-1.0). It is excluded from the +repo's app-release version sync (`scripts/set-version.ts` skips published +packages). Releases are cut by tagging `host-cli-vX.Y.Z`, which builds, +tests, and publishes from CI. See `CHANGELOG.md`. diff --git a/packages/host-cli/eslint.config.js b/packages/host-cli/eslint.config.js new file mode 100644 index 00000000..887d15ee --- /dev/null +++ b/packages/host-cli/eslint.config.js @@ -0,0 +1,15 @@ +// Copyright 2026 Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: AGPL-3.0-only + +import { config } from "@dotli/eslint-config/base"; + +export default [ + ...config, + { + languageOptions: { + parserOptions: { + tsconfigRootDir: import.meta.dirname, + }, + }, + }, +]; diff --git a/packages/host-cli/examples/pair.ts b/packages/host-cli/examples/pair.ts new file mode 100644 index 00000000..65133dc4 --- /dev/null +++ b/packages/host-cli/examples/pair.ts @@ -0,0 +1,78 @@ +// Copyright 2026 Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: AGPL-3.0-only + +// Runnable pairing demo: renders a QR in your terminal, waits for a phone +// scan, and round-trips a product localStorage value through the paired core. +// +// bun examples/pair.ts # or: node --experimental-strip-types +// +// Needs network egress to the Paseo Next V2 endpoints below, and a phone with +// the Polkadot app to scan the QR. State lands in ~/.dotli-host-cli-example +// (0600). Delete the directory to force a re-pair. + +import { homedir } from "node:os"; +import { join } from "node:path"; +import { createClient, createTransport } from "@parity/truapi"; +import { createCliHost, explainProductError } from "../src/index.js"; + +// Paseo Next V2, the same values dotli's web host uses. An embedding app +// supplies its own network map: the host is parameterized, not opinionated. +const PEOPLE_GENESIS = + "0xc5af1826b31493f08b7e2a823842f98575b806a784126f28da9608c68665afa5"; +const BULLETIN_GENESIS = + "0x8cfe6717dc4becfda2e13c488a1e2061ff2dfee96e7d031157f72d36716c0a22"; +const ASSET_HUB_GENESIS = + "0xbf0488dbe9daa1de1c08c5f743e26fdc2a4ecd74cf87dd1b4b1eeb99ae4ef19f"; + +const host = await createCliHost({ + host: { name: "host-cli example", version: "0.1.0" }, + pairing: { deeplinkScheme: "polkadotapp" }, + people: { genesisHash: PEOPLE_GENESIS }, + bulletin: { genesisHash: BULLETIN_GENESIS }, + chains: { + [ASSET_HUB_GENESIS]: { + name: "Asset Hub", + rpc: "wss://paseo-asset-hub-next-rpc.polkadot.io", + }, + [PEOPLE_GENESIS]: { + name: "People", + rpc: "wss://paseo-people-next-system-rpc.polkadot.io", + }, + [BULLETIN_GENESIS]: { + name: "Bulletin", + rpc: "wss://paseo-bulletin-next-rpc.polkadot.io", + }, + }, + storageDir: join(homedir(), ".dotli-host-cli-example"), +}); + +const product = host.createProduct({ productId: "host-cli-example.dot" }); +const client = createClient(createTransport(product.provider)); + +// Drive login from the product side. The host renders the QR and progress. +// On an already-paired store this resolves in milliseconds with no phone. +try { + const outcome = await client.account.requestLogin({ + reason: "host-cli pairing example", + }); + console.log(`requestLogin -> ${JSON.stringify(outcome)}`); +} catch (error) { + // The 180s wallet timeout surfaces as a bare TxError with no message. + // explainProductError turns that into something a user can act on. + console.error(explainProductError(error) ?? error); + process.exit(1); +} + +const written = await client.localStorage.write({ + key: "example", + value: "0xdeadbeef", +}); +console.log(`localStorage.write -> ${written.isOk() ? "Ok" : "Err"}`); +const read = await client.localStorage.read({ key: "example" }); +console.log( + `localStorage.read -> ${read.isOk() ? JSON.stringify(read.value) : "Err"}`, +); + +product.dispose(); +host.dispose(); +process.exit(0); diff --git a/packages/host-cli/package.json b/packages/host-cli/package.json new file mode 100644 index 00000000..5ba22fd9 --- /dev/null +++ b/packages/host-cli/package.json @@ -0,0 +1,53 @@ +{ + "name": "@dotli/host-cli", + "version": "0.1.0", + "private": false, + "description": "Terminal host for the TrUAPI Rust/WASM core: pairing QR, confirm prompts, owner-only file storage, and pooled chain connections for CLI products.", + "license": "AGPL-3.0-only", + "type": "module", + "repository": { + "type": "git", + "url": "git+https://github.com/paritytech/dotli-community.git", + "directory": "packages/host-cli" + }, + "engines": { + "node": ">=22" + }, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "files": [ + "dist", + "README.md", + "CHANGELOG.md", + "LICENSE" + ], + "publishConfig": { + "access": "public" + }, + "scripts": { + "build": "tsc -p tsconfig.build.json", + "prepack": "bun run build", + "typecheck": "tsc --noEmit", + "lint": "bunx eslint src/ tests/", + "test": "vitest run" + }, + "dependencies": { + "@parity/truapi-host": "0.2.1", + "neverthrow": "^8.2.0", + "qrcode": "^1.5.4" + }, + "devDependencies": { + "@dotli/eslint-config": "workspace:*", + "@dotli/typescript-config": "workspace:*", + "@parity/truapi": "0.5.1", + "@types/node": "^22.0.0", + "@types/qrcode": "^1.5.6", + "eslint": "^10.5.0", + "typescript": "~6.0.3", + "vitest": "^4.1.8" + } +} diff --git a/packages/host-cli/src/callbacks.ts b/packages/host-cli/src/callbacks.ts new file mode 100644 index 00000000..fa8f81fe --- /dev/null +++ b/packages/host-cli/src/callbacks.ts @@ -0,0 +1,198 @@ +// Copyright 2026 Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: AGPL-3.0-only + +// The 17 typed host callbacks (11 groups), implemented for a terminal host. +// The generated adapter (`createWasmRawCallbacks`) turns these into the raw +// SCALE surface the wasm core calls. Hand-written SCALE callbacks are exactly +// the drift this package exists to avoid. + +import { ok } from "neverthrow"; +import type { + AuthState, + CoreStorageKey, + RequiredHostCallbacks, +} from "@parity/truapi-host"; +import type { ChainEndpoints, ChainPool } from "./chain-pool.js"; +import { fromHex, toHex } from "./hex.js"; +import type { KeyValueStore } from "./kv.js"; +import type { HostPresenter } from "./presenter.js"; +import { describeReview } from "./reviews.js"; + +export interface HostCallbackDeps { + coreStore: KeyValueStore; + productStore: KeyValueStore; + pool: ChainPool; + presenter: HostPresenter; + endpoints: ChainEndpoints; + theme: "Dark" | "Light"; + /** Optional preimage retrieval backend (P2P/IPFS). Default: always a miss. */ + lookupPreimage?: (key: Uint8Array) => Promise; + onAuthState: (state: AuthState) => void; + log?: (line: string) => void; +} + +/** + * Flatten a typed core-storage slot to a stable, legible backing key. Slot + * tags are unique. The parameterized slots carry their parameters. + */ +export function coreSlot(key: CoreStorageKey): string { + switch (key.tag) { + case "AllowanceKeys": + return `AllowanceKeys:${key.value.sessionId}`; + case "PermissionAuthorization": + return `PermissionAuthorization:${key.value.productId}:${key.value.request.tag}`; + default: + return key.tag; + } +} + +const park = (): Promise => new Promise(() => {}); + +export function createHostCallbacks( + deps: HostCallbackDeps, +): RequiredHostCallbacks { + const { + coreStore, + productStore, + pool, + presenter, + endpoints, + theme, + lookupPreimage, + onAuthState, + log, + } = deps; + let nextNotificationId = 1; + + return { + navigation: { + // NOT the pairing affordance. Pairing arrives as `AuthState.Pairing`. + // This is "open a URL in the system browser", which a terminal host + // hands to the user instead of guessing at an opener. + async navigateTo(url) { + presenter.openUrl(url); + }, + }, + + notifications: { + async pushNotification(notification) { + presenter.notify( + notification.deeplink === undefined + ? notification.text + : `${notification.text} (${notification.deeplink})`, + ); + return { id: nextNotificationId++ }; + }, + async cancelNotification(id) { + // Notifications are printed, not retained. Cancelling is idempotently + // a no-op by contract. + log?.(`cancelNotification(${String(id)})`); + }, + }, + + permissions: { + async devicePermission(request) { + const granted = await presenter.confirm({ + title: `Allow access to: ${request}`, + details: [], + phoneVerifies: false, + }); + return { granted }; + }, + async remotePermission(request) { + const granted = await presenter.confirm({ + title: "Grant a product permission", + details: [`permission: ${JSON.stringify(request.permission)}`], + phoneVerifies: false, + }); + return { granted }; + }, + }, + + features: { + async featureSupported(request) { + // The only feature probe today is per-chain support. This host serves + // exactly the chains it has endpoints for. + const supported = + request.tag === "Chain" && + endpoints[request.value.genesisHash] !== undefined; + return { supported }; + }, + }, + + productStorage: { + // The core namespaces these keys itself + // (`truapi:product-storage:v1:::`). The key carries + // NO account component, which is why the host clears this store on + // logout (see CliHost). + async read(key) { + const hit = await productStore.get(key); + return hit === null ? undefined : fromHex(hit); + }, + async write(key, value) { + await productStore.set(key, toHex(value)); + }, + async clear(key) { + await productStore.delete(key); + }, + }, + + coreStorage: { + async readCoreStorage(key) { + const hit = await coreStore.get(coreSlot(key)); + return hit === null ? undefined : fromHex(hit); + }, + async writeCoreStorage(key, value) { + await coreStore.set(coreSlot(key), toHex(value)); + }, + async clearCoreStorage(key) { + await coreStore.delete(coreSlot(key)); + }, + }, + + chain: { + connect(genesisHash) { + return pool.connect(genesisHash); + }, + }, + + auth: { + authStateChanged(state) { + onAuthState(state); + }, + }, + + userConfirmation: { + confirmUserAction(review) { + return presenter.confirm(describeReview(review, { endpoints })); + }, + }, + + theme: { + async *subscribeTheme() { + yield ok(theme); + // A terminal theme never changes mid-run. Park forever so the core's + // subscription stays open instead of seeing an immediate + // end-of-stream. + await park(); + }, + }, + + preimage: { + async *lookupPreimage(key) { + let value: Uint8Array | undefined; + try { + value = await lookupPreimage?.(key); + } catch (error) { + log?.( + `lookupPreimage(${toHex(key).slice(0, 18)}…) failed: ${String(error)}`, + ); + value = undefined; + } + yield ok(value); + // Same contract as `theme`: emit once, then keep the stream open. + await park(); + }, + }, + }; +} diff --git a/packages/host-cli/src/chain-pool.ts b/packages/host-cli/src/chain-pool.ts new file mode 100644 index 00000000..0a55740e --- /dev/null +++ b/packages/host-cli/src/chain-pool.ts @@ -0,0 +1,343 @@ +// Copyright 2026 Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: AGPL-3.0-only + +// Pooled JSON-RPC connections, keyed by genesis hash. +// +// The core opens a chain connection per need and does not pool (measured: +// three People-chain sockets during pairing, plus one per boot). The web host +// hides that behind dotli's chain broker. This is the terminal equivalent, +// deliberately leaner. +// +// - Request ids are rewritten per lease, so responses route exactly. +// - Subscription tokens are learned from string-typed results (substrate's +// JSON-RPC v2 subscribe calls all return the token as a string result), +// and notifications route by `params.subscription`. +// - Delivery is synchronous in socket arrival order, so sharing a socket +// cannot introduce message reordering. Reordering is the class of bug +// behind the chain_head operation-ordering hazard. +// - Each socket carries a bounded number of leases (default 2), because +// substrate nodes cap `chainHead_v1_follow` subscriptions per connection. +// Pooling bounds sockets per endpoint at ceil(leases / cap) instead of N. + +import type { JsonRpcConnection } from "@parity/truapi-host"; +import { toHex } from "./hex.js"; + +export interface ChainEndpoint { + /** WebSocket JSON-RPC endpoint. */ + rpc: string; + /** Optional label used in logs and confirm prompts. */ + name?: string; +} + +/** Chains this host serves, keyed by `0x`-prefixed genesis hash. */ +export type ChainEndpoints = Record; + +/** The subset of WebSocket the pool needs. Injectable for tests. */ +export interface SocketLike { + addEventListener(type: string, listener: (event: unknown) => void): void; + send(data: string): void; + close(): void; +} + +export interface ChainPoolOptions { + endpoints: ChainEndpoints; + /** + * Leases sharing one socket. Substrate's `chainHead_v1_follow` is capped per + * connection, so this stays conservative by default. + */ + maxLeasesPerSocket?: number; + /** Socket factory, injectable for tests. Defaults to node's `WebSocket`. */ + createSocket?: (url: string) => SocketLike; + log?: (line: string) => void; +} + +interface Lease { + deliver(json: string): void; + end(): void; +} + +interface PooledSocket { + socket: SocketLike; + open: boolean; + closed: boolean; + outbox: string[]; + leases: Set; + /** rewritten request id -> owner and the id to restore on the response. */ + pending: Map; + /** subscription token -> owning lease. */ + tokens: Map; +} + +export interface ChainPool { + connect(genesisHash: Uint8Array | string): Promise; + /** Open sockets right now, per endpoint URL (observability and tests). */ + socketCounts(): Record; + closeAll(): void; +} + +export function createChainPool(options: ChainPoolOptions): ChainPool { + const { + endpoints, + maxLeasesPerSocket = 2, + createSocket = (url) => new WebSocket(url) as unknown as SocketLike, + log, + } = options; + const socketsByGenesis = new Map(); + let nextRequestId = 0; + + function openSocket(genesisHash: string, url: string): PooledSocket { + const entry: PooledSocket = { + socket: createSocket(url), + open: false, + closed: false, + outbox: [], + leases: new Set(), + pending: new Map(), + tokens: new Map(), + }; + entry.socket.addEventListener("open", () => { + entry.open = true; + for (const request of entry.outbox.splice(0)) { + entry.socket.send(request); + } + }); + entry.socket.addEventListener("message", (event) => { + if (entry.closed) { + return; + } + route(entry, String((event as { data: unknown }).data)); + }); + entry.socket.addEventListener("error", () => { + log?.(`chain[${url}] socket error`); + }); + entry.socket.addEventListener("close", () => { + teardown(genesisHash, entry); + }); + return entry; + } + + function teardown(genesisHash: string, entry: PooledSocket): void { + if (entry.closed) { + return; + } + entry.closed = true; + try { + entry.socket.close(); + } catch { + // Already gone. + } + for (const lease of [...entry.leases]) { + lease.end(); + } + entry.leases.clear(); + entry.pending.clear(); + entry.tokens.clear(); + const pool = socketsByGenesis.get(genesisHash); + if (pool !== undefined) { + const remaining = pool.filter((candidate) => candidate !== entry); + if (remaining.length === 0) { + socketsByGenesis.delete(genesisHash); + } else { + socketsByGenesis.set(genesisHash, remaining); + } + } + } + + function route(entry: PooledSocket, json: string): void { + let message: Record; + try { + message = JSON.parse(json) as Record; + } catch { + log?.(`chain: dropping unparseable message (${json.slice(0, 60)}…)`); + return; + } + + // A response: route by the rewritten id, restore the original. + if (typeof message.method !== "string") { + const owner = + typeof message.id === "string" + ? entry.pending.get(message.id) + : undefined; + if (owner === undefined) { + // The owning lease closed, or the server sent an id we never issued. + return; + } + entry.pending.delete(message.id as string); + if (typeof message.result === "string") { + // Every substrate subscribe call returns its token as a string result. + // False positives (e.g. a hex string from `chainSpec_v1_genesisHash`) + // are harmless: the entry simply never receives a notification. + entry.tokens.set(message.result, owner.lease); + } + owner.lease.deliver(JSON.stringify({ ...message, id: owner.originalId })); + return; + } + + // A notification: route by subscription token. + const params = message.params as { subscription?: unknown } | undefined; + const token = params?.subscription; + if (typeof token === "string" || typeof token === "number") { + const owner = entry.tokens.get(String(token)); + if (owner === undefined) { + // Token of a closed lease (its server-side subscription outlives the + // lease until the socket closes), or one we failed to learn. Dropping + // is safe for the former. The latter cannot happen for substrate's + // string-result subscribe calls. + return; + } + owner.deliver(json); + return; + } + + log?.(`chain: dropping unroutable message (${json.slice(0, 80)}…)`); + } + + function sendFrom(entry: PooledSocket, lease: Lease, request: string): void { + if (entry.closed) { + return; + } + let rewritten = request; + try { + const message = JSON.parse(request) as Record; + if (message.id !== undefined && message.id !== null) { + // "hcp" is the host-cli pool namespace. The prefix guarantees a + // rewritten id can never collide with an id some lease chose itself. + const poolId = `hcp:${String(nextRequestId++)}`; + entry.pending.set(poolId, { lease, originalId: message.id }); + rewritten = JSON.stringify({ ...message, id: poolId }); + } + } catch { + // Not JSON we can rewrite. Forward as-is (its response, if any, will be + // unroutable and dropped). + } + if (entry.open) { + entry.socket.send(rewritten); + } else { + entry.outbox.push(rewritten); + } + } + + function dropLease( + genesisHash: string, + entry: PooledSocket, + lease: Lease, + ): void { + entry.leases.delete(lease); + for (const [id, owner] of [...entry.pending]) { + if (owner.lease === lease) { + entry.pending.delete(id); + } + } + for (const [token, owner] of [...entry.tokens]) { + if (owner === lease) { + entry.tokens.delete(token); + } + } + if (entry.leases.size === 0) { + teardown(genesisHash, entry); + } + } + + return { + async connect(genesisHash) { + const genesis = + typeof genesisHash === "string" ? genesisHash : toHex(genesisHash); + const endpoint = endpoints[genesis]; + if (endpoint === undefined) { + // Throwing tells the core no provider is available for this chain. + throw new Error(`no RPC endpoint configured for ${genesis}`); + } + + const pool = socketsByGenesis.get(genesis) ?? []; + let entry = pool.find( + (candidate) => + !candidate.closed && candidate.leases.size < maxLeasesPerSocket, + ); + if (entry === undefined) { + entry = openSocket(genesis, endpoint.rpc); + socketsByGenesis.set(genesis, [...pool, entry]); + log?.( + `chain[${endpoint.name ?? genesis.slice(0, 12)}] opening socket #${String( + (socketsByGenesis.get(genesis) ?? []).length, + )}`, + ); + } + + const inbox: string[] = []; + let wake: (() => void) | null = null; + let ended = false; + const lease: Lease = { + deliver(json) { + if (ended) { + return; + } + inbox.push(json); + wake?.(); + wake = null; + }, + end() { + ended = true; + wake?.(); + wake = null; + }, + }; + entry.leases.add(lease); + const owner = entry; + + const close = (): void => { + if (ended) { + return; + } + lease.end(); + dropLease(genesis, owner, lease); + }; + + return { + send(request: string): void { + if (!ended) { + sendFrom(owner, lease, request); + } + }, + async *responses(): AsyncIterable { + try { + while (!ended) { + while (inbox.length > 0) { + const next = inbox.shift(); + if (next !== undefined) { + yield next; + } + } + if (ended) { + break; + } + await new Promise((resolve) => { + wake = resolve; + }); + } + } finally { + close(); + } + }, + close, + }; + }, + + socketCounts() { + const counts: Record = {}; + for (const [genesis, pool] of socketsByGenesis) { + const url = endpoints[genesis]?.rpc ?? genesis; + counts[url] = (counts[url] ?? 0) + pool.filter((s) => !s.closed).length; + } + return counts; + }, + + closeAll() { + for (const [genesis, pool] of [...socketsByGenesis]) { + for (const entry of [...pool]) { + teardown(genesis, entry); + } + } + socketsByGenesis.clear(); + }, + }; +} diff --git a/packages/host-cli/src/errors.ts b/packages/host-cli/src/errors.ts new file mode 100644 index 00000000..062c9ef7 --- /dev/null +++ b/packages/host-cli/src/errors.ts @@ -0,0 +1,46 @@ +// Copyright 2026 Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: AGPL-3.0-only + +// Error translation the core does not (yet) provide. +// +// A wallet that never answers an SSO request produces, after ~180 seconds, a +// BARE `{isSdkError: true, source: "tx", name: "TxError"}` with no message. +// The core's own diagnosis ("SSO response timed out") exists only as a +// `tracing` warning, so a product cannot tell "phone never answered" from any +// other transaction failure. Until the timeout becomes a typed error variant +// upstream, a bare `TxError` is treated as a probable timeout. + +/** Whether `error` looks like the wallet-SSO 180s timeout. */ +export function isProbableSsoTimeout(error: unknown): boolean { + if (typeof error !== "object" || error === null) { + return false; + } + const candidate = error as { + isSdkError?: unknown; + source?: unknown; + name?: unknown; + message?: unknown; + }; + return ( + candidate.isSdkError === true && + candidate.source === "tx" && + candidate.name === "TxError" && + (candidate.message === undefined || + candidate.message === null || + candidate.message === "") + ); +} + +/** + * A user-facing explanation for errors a product call can surface through the + * core, or `undefined` when there is nothing better than the error itself. + */ +export function explainProductError(error: unknown): string | undefined { + if (isProbableSsoTimeout(error)) { + return ( + "No response from your phone. The signing request most likely timed " + + "out. Open the Polkadot app, check for a pending request, and retry." + ); + } + return undefined; +} diff --git a/packages/host-cli/src/hex.ts b/packages/host-cli/src/hex.ts new file mode 100644 index 00000000..36a7390f --- /dev/null +++ b/packages/host-cli/src/hex.ts @@ -0,0 +1,18 @@ +// Copyright 2026 Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: AGPL-3.0-only + +export const toHex = (bytes: Uint8Array): string => + `0x${Buffer.from(bytes).toString("hex")}`; + +export const fromHex = (hex: string): Uint8Array => + new Uint8Array(Buffer.from(hex.replace(/^0x/, ""), "hex")); + +/** Byte length of a hex string, with or without its `0x` prefix. */ +export const hexByteLength = (hex: string): number => + hex.replace(/^0x/, "").length / 2; + +/** Shorten a key for display, e.g. `0x84ccf320…be6232`. Recognizable without clutter. */ +export const shortHex = (bytes: Uint8Array): string => { + const hex = toHex(bytes); + return hex.length <= 14 ? hex : `${hex.slice(0, 10)}…${hex.slice(-6)}`; +}; diff --git a/packages/host-cli/src/host.ts b/packages/host-cli/src/host.ts new file mode 100644 index 00000000..1aa2c009 --- /dev/null +++ b/packages/host-cli/src/host.ts @@ -0,0 +1,307 @@ +// Copyright 2026 Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: AGPL-3.0-only + +// The CLI host: boots the Rust core in-process and owns everything the core +// leaves to a host. That means rendering (via the presenter), owner-only +// persistence, pooled chain connections, and clearing product storage across +// identities. +// +// It is parameterized by the EMBEDDING app's metadata: for a terminal app the +// app is its own host (d3pot is not "a product inside dotli", it IS the +// host), so name/icon/version, the pairing deeplink scheme, and the +// people/bulletin genesis hashes all come from the caller. + +import { join } from "node:path"; +import type { AuthState, WireProvider } from "@parity/truapi-host"; +import { createChainPool, type ChainEndpoints } from "./chain-pool.js"; +import { createHostCallbacks } from "./callbacks.js"; +import { toHex } from "./hex.js"; +import { FileKeyValueStore } from "./kv.js"; +import { createLoopbackProvider } from "./loopback.js"; +import { createTerminalPresenter, type HostPresenter } from "./presenter.js"; +import { loadWasmCore } from "./wasm.js"; + +export interface CliHostConfig { + /** Metadata describing the embedding app, shown by the wallet on pairing. */ + host: { name: string; icon?: string; version?: string }; + /** Wallet pairing deeplink scheme (the wallet's URI scheme, not yours). */ + pairing: { deeplinkScheme: string }; + /** People-chain genesis hash (identity lookup), `0x`-prefixed hex. */ + people: { genesisHash: string }; + /** Bulletin-chain genesis hash (in-core preimage submission). */ + bulletin: { genesisHash: string }; + /** + * Chains this host serves, keyed by genesis hash. Also answers the core's + * `featureSupported` probes. + */ + chains: ChainEndpoints; + /** Directory for the host's persistent state. Files are created 0600. */ + storageDir: string; + /** Defaults to the current OS and node version. */ + platform?: { type?: string; version?: string }; + /** Rendering/prompting surface. Defaults to a stderr terminal presenter. */ + presenter?: HostPresenter; + theme?: "Dark" | "Light"; + /** + * Core `tracing` verbosity. Defaults to `warn` because the core's + * SSO-timeout diagnosis is ONLY visible at `warn` or above today. + */ + logLevel?: string; + /** Optional preimage retrieval backend. Default: always a miss. */ + lookupPreimage?: (key: Uint8Array) => Promise; + /** Leases per pooled socket; see chain-pool. */ + maxLeasesPerSocket?: number; + /** Override the `@parity/truapi-host` dist directory (exotic setups). */ + wasmDir?: string; + /** Diagnostics sink for non-user-facing host events. */ + log?: (line: string) => void; +} + +export interface CliHostProduct { + productId: string; + /** + * The product side of the wire. Feed it to `@parity/truapi`'s + * `createTransport`/`createClient` (or product-sdk's injection seam). + */ + provider: WireProvider; + /** Core-owned logout via this product runtime; clears product storage. */ + disconnectSession(): Promise; + dispose(): void; +} + +export interface CliHost { + /** Instantiate one product core over an in-process loopback wire. */ + createProduct(options: { productId: string }): CliHostProduct; + /** Cancel an in-flight pairing (the user gave up on the QR). */ + cancelPairing(): void; + /** Core-owned logout, then clears product storage (keys carry no account). */ + disconnectSession(): Promise; + /** Tell the core the persisted auth-session blob may have changed. */ + notifySessionStoreChanged(): void; + /** + * The last auth state the core emitted, or `undefined` before the first + * emission. The core emits NOTHING at boot when unauthenticated. Render + * logged-out from absence, do not wait for a state. + */ + authState(): AuthState | undefined; + onAuthState(listener: (state: AuthState) => void): () => void; + waitForAuthState( + predicate: (state: AuthState) => boolean, + timeoutMs?: number, + ): Promise; + /** + * Wipe core-namespaced product storage. Called automatically on logout and + * when a DIFFERENT identity connects (product-storage keys are scoped by + * product id only, so the next identity would inherit the previous one's + * data). + */ + clearProductStorage(): Promise; + storagePaths: { core: string; product: string }; + dispose(): void; +} + +function defaultPlatformType(): string { + switch (process.platform) { + case "darwin": + return "macOS"; + case "linux": + return "Linux"; + case "win32": + return "Windows"; + default: + return process.platform; + } +} + +const LAST_IDENTITY_KEY = "lastConnectedIdentity"; + +export async function createCliHost(config: CliHostConfig): Promise { + const core = await loadWasmCore({ wasmDir: config.wasmDir }); + core.bindings.setLogLevel(config.logLevel ?? "warn"); + + const coreStore = new FileKeyValueStore( + join(config.storageDir, "core-storage.json"), + ); + const productStore = new FileKeyValueStore( + join(config.storageDir, "product-storage.json"), + ); + // Host-private bookkeeping (NOT a core storage slot): which identity last + // connected, so a crash between logout and login still cannot leak one + // identity's product storage to the next. + const metaStore = new FileKeyValueStore( + join(config.storageDir, "host-meta.json"), + ); + + const pool = createChainPool({ + endpoints: config.chains, + maxLeasesPerSocket: config.maxLeasesPerSocket, + log: config.log, + }); + + const ownsPresenter = config.presenter === undefined; + const presenter = config.presenter ?? createTerminalPresenter(); + + let lastAuthState: AuthState | undefined; + const authListeners = new Set<(state: AuthState) => void>(); + // Identity checks are async but auth emissions are ordered. Serialize the + // reactions so a Connected/Disconnected flurry cannot interleave clears. + let identityChain: Promise = Promise.resolve(); + + const dispatchAuthState = (state: AuthState): void => { + lastAuthState = state; + if (state.tag === "Connected") { + const identity = toHex(state.value.publicKey); + identityChain = identityChain + .catch(() => {}) + .then(async () => { + const previous = await metaStore.get(LAST_IDENTITY_KEY); + if (previous !== null && previous !== identity) { + config.log?.( + "different identity connected; clearing product storage", + ); + await productStore.clear(); + } + await metaStore.set(LAST_IDENTITY_KEY, identity); + }); + } + presenter.authStateChanged(state); + for (const listener of [...authListeners]) { + listener(state); + } + }; + + const callbacks = createHostCallbacks({ + coreStore, + productStore, + pool, + presenter, + endpoints: config.chains, + theme: config.theme ?? "Dark", + lookupPreimage: config.lookupPreimage, + onAuthState: dispatchAuthState, + log: config.log, + }); + + const runtime = new core.bindings.WasmPairingHostRuntime( + core.createRawCallbacks(callbacks), + { + host: config.host, + platform: { + type: config.platform?.type ?? defaultPlatformType(), + version: config.platform?.version ?? process.version, + }, + people: { genesisHash: config.people.genesisHash }, + bulletin: { genesisHash: config.bulletin.genesisHash }, + pairing: { deeplinkScheme: config.pairing.deeplinkScheme }, + }, + ); + + const products = new Set(); + let disposed = false; + + const clearProductStorage = async (): Promise => { + await productStore.clear(); + }; + + return { + createProduct({ productId }) { + const { provider, core: productCore } = createLoopbackProvider( + (coreCallbacks) => runtime.productRuntime({ productId }, coreCallbacks), + { + onReceiveError: (error) => { + config.log?.(`receiveFrame(${productId}) failed: ${String(error)}`); + }, + }, + ); + const product: CliHostProduct = { + productId, + provider, + async disconnectSession() { + await productCore.disconnectSession(); + await clearProductStorage(); + }, + dispose() { + products.delete(product); + provider.dispose(); + productCore.dispose(); + }, + }; + products.add(product); + return product; + }, + + cancelPairing() { + runtime.cancelPairing(); + }, + + async disconnectSession() { + await runtime.disconnectSession(); + await clearProductStorage(); + }, + + notifySessionStoreChanged() { + runtime.notifySessionStoreChanged(); + }, + + authState() { + return lastAuthState; + }, + + onAuthState(listener) { + authListeners.add(listener); + return () => authListeners.delete(listener); + }, + + waitForAuthState(predicate, timeoutMs) { + if (lastAuthState !== undefined && predicate(lastAuthState)) { + return Promise.resolve(lastAuthState); + } + return new Promise((resolve, reject) => { + const timer = + timeoutMs === undefined + ? undefined + : setTimeout(() => { + authListeners.delete(listener); + reject( + new Error( + `timed out after ${String(timeoutMs)}ms waiting for an auth state`, + ), + ); + }, timeoutMs); + const listener = (state: AuthState): void => { + if (!predicate(state)) { + return; + } + authListeners.delete(listener); + if (timer !== undefined) { + clearTimeout(timer); + } + resolve(state); + }; + authListeners.add(listener); + }); + }, + + clearProductStorage, + + storagePaths: { + core: coreStore.filePath, + product: productStore.filePath, + }, + + dispose() { + if (disposed) { + return; + } + disposed = true; + for (const product of [...products]) { + product.dispose(); + } + runtime.free(); + pool.closeAll(); + if (ownsPresenter) { + presenter.dispose(); + } + }, + }; +} diff --git a/packages/host-cli/src/index.ts b/packages/host-cli/src/index.ts new file mode 100644 index 00000000..8cc5ef61 --- /dev/null +++ b/packages/host-cli/src/index.ts @@ -0,0 +1,53 @@ +// Copyright 2026 Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: AGPL-3.0-only + +// @dotli/host-cli, the terminal host for the TrUAPI Rust/WASM core. +// +// A peer of dotli's web host, not a layer on top of it: both depend directly +// on `@parity/truapi-host` (the host engine) and implement its 17 platform +// callbacks. The web host answers with modals and localStorage. This one +// answers with a pairing QR, readline prompts, owner-only files, and pooled +// sockets. + +export { + createCliHost, + type CliHost, + type CliHostConfig, + type CliHostProduct, +} from "./host.js"; +export { + createTerminalPresenter, + type HostPresenter, + type TerminalPresenterOptions, +} from "./presenter.js"; +export { describeReview, type ConfirmRequest } from "./reviews.js"; +export { + createChainPool, + type ChainEndpoint, + type ChainEndpoints, + type ChainPool, + type ChainPoolOptions, + type SocketLike, +} from "./chain-pool.js"; +export { + FileKeyValueStore, + InMemoryKeyValueStore, + type KeyValueStore, +} from "./kv.js"; +export { + createLoopbackProvider, + type CoreWireCallbacks, + type FrameReceiver, +} from "./loopback.js"; +export { + createHostCallbacks, + coreSlot, + type HostCallbackDeps, +} from "./callbacks.js"; +export { + serializeOperationStarts, + type JsonRpcProvider, +} from "./operation-order.js"; +export { explainProductError, isProbableSsoTimeout } from "./errors.js"; +export { renderQrTerminal } from "./qr.js"; +export { loadWasmCore, type WasmCore } from "./wasm.js"; diff --git a/packages/host-cli/src/kv.ts b/packages/host-cli/src/kv.ts new file mode 100644 index 00000000..4b2ab7df --- /dev/null +++ b/packages/host-cli/src/kv.ts @@ -0,0 +1,184 @@ +// Copyright 2026 Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: AGPL-3.0-only + +// Node KeyValueStore implementations backing the core's storage callbacks. +// Lifted from the earlier @dotli/host-node package (feat/host-core lineage), +// which hardened them against racing writes and world-readable files. + +import { readFile, writeFile, mkdir, chmod, rm } from "node:fs/promises"; +import { dirname } from "node:path"; + +/** Plain async string KV. Values here are hex-encoded core payloads. */ +export interface KeyValueStore { + get(key: string): Promise; + set(key: string, value: string): Promise; + delete(key: string): Promise; + keys(prefix: string): Promise; +} + +/** Ephemeral, process-lifetime store. Good for CLI one-shots and tests. */ +export class InMemoryKeyValueStore implements KeyValueStore { + private readonly map = new Map(); + + async get(key: string): Promise { + return this.map.get(key) ?? null; + } + + async set(key: string, value: string): Promise { + this.map.set(key, value); + } + + async delete(key: string): Promise { + this.map.delete(key); + } + + async keys(prefix: string): Promise { + return [...this.map.keys()].filter((k) => k.startsWith(prefix)); + } +} + +/** + * Permissions for the store and its directory: owner-only. + * + * The core routes a product's `localStorage` straight into this store, and the + * core-storage file holds the SSO session blob and allowance keys. Default + * permissions would make that 0644, i.e. readable by every other local user on + * a shared build machine or CI box. + */ +const STORE_MODE = 0o600; +const STORE_DIR_MODE = 0o700; + +/** + * Persistent store backed by a single JSON file. Loaded once on first access + * and written back on every mutation. Suitable for a long-lived CLI. For heavy + * write loads prefer a real embedded database. + * + * The file is owner-only (see {@link STORE_MODE}). Note the store is keyed by + * whatever prefixes the caller uses. The core scopes product storage by + * product id, NOT by account or session, so a consumer that supports + * switching identities must clear the file itself on logout, or the next + * identity inherits the previous one's host storage. The path is available as + * {@link FileKeyValueStore.filePath} for exactly that. + */ +export class FileKeyValueStore implements KeyValueStore { + private cache: Record | null = null; + // The IN-FLIGHT load, memoised. Serializing only the write chain was not enough: + // two concurrent set() calls both found `cache === null`, both issued their own + // readFile, and the second assignment discarded the first caller's mutation. One + // key vanished from the file and from memory while BOTH writes resolved + // successfully. Sharing the read fixes it, since after it settles every caller + // mutates the same object. + private loading: Promise> | null = null; + private readonly path: string; + // Serialize writes so two overlapping flushes can never tear the file. + private writeChain: Promise = Promise.resolve(); + + constructor(path: string) { + this.path = path; + } + + /** + * Where this store persists, so a consumer can clear it on logout. The + * core scopes entries by product id, not by account, so leaving the file + * behind hands the next identity the previous one's host storage. + */ + get filePath(): string { + return this.path; + } + + private load(): Promise> { + if (this.cache !== null) { + return Promise.resolve(this.cache); + } + if (this.loading !== null) { + return this.loading; + } + this.loading = (async () => { + try { + this.cache = JSON.parse(await readFile(this.path, "utf8")) as Record< + string, + string + >; + } catch { + // Missing or unreadable file starts an empty store. A malformed file is + // treated the same so a corrupt cache never wedges the host. + this.cache = {}; + } finally { + this.loading = null; + } + return this.cache; + })(); + return this.loading; + } + + private flush(): Promise { + // Chain each write after the previous one. The `.catch` keeps a failed + // write from poisoning the chain for later writes. + this.writeChain = this.writeChain + .catch(() => {}) + .then(async () => { + await mkdir(dirname(this.path), { + recursive: true, + mode: STORE_DIR_MODE, + }); + await writeFile(this.path, JSON.stringify(this.cache ?? {}), { + encoding: "utf8", + mode: STORE_MODE, + }); + // `mode` on writeFile only applies when the file is CREATED, so a store + // written before this was tightened would stay 0644 forever. chmod every + // flush to self-heal those. + await chmod(this.path, STORE_MODE).catch(() => { + // Not our file, or a filesystem without POSIX modes. The write itself + // succeeded, so don't fail the store over its permissions. + }); + }); + return this.writeChain; + } + + // Mutations go through `this.cache` AFTER the load settles, never through + // the object `load()` resolved with. `clear()` swaps the cache object out, + // so a mutation on the resolved reference could land on a detached object + // and a successfully-resolved write would silently vanish. + async get(key: string): Promise { + await this.load(); + return (this.cache ??= {})[key] ?? null; + } + + async set(key: string, value: string): Promise { + await this.load(); + (this.cache ??= {})[key] = value; + await this.flush(); + } + + async delete(key: string): Promise { + await this.load(); + delete (this.cache ??= {})[key]; + await this.flush(); + } + + async keys(prefix: string): Promise { + await this.load(); + return Object.keys((this.cache ??= {})).filter((k) => k.startsWith(prefix)); + } + + /** + * Drop every entry and delete the backing file. The next identity to log in + * must not inherit this one's storage (core product-storage keys carry no + * account component), so logout calls this. + */ + async clear(): Promise { + // Let an in-flight load settle first. Its `this.cache = ...` assignment + // would otherwise resurrect stale data over the cleared cache. + if (this.loading !== null) { + await this.loading; + } + this.cache = {}; + // Ride the write chain so an in-flight flush cannot resurrect the file + // after the removal. + this.writeChain = this.writeChain + .catch(() => {}) + .then(() => rm(this.path, { force: true })); + await this.writeChain; + } +} diff --git a/packages/host-cli/src/loopback.ts b/packages/host-cli/src/loopback.ts new file mode 100644 index 00000000..fd94012e --- /dev/null +++ b/packages/host-cli/src/loopback.ts @@ -0,0 +1,60 @@ +// Copyright 2026 Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: AGPL-3.0-only + +// The loopback wire: product client <-> core, in one process. +// +// This is the structural difference from the web host: no iframe, no +// postMessage, no separate origin. The product and the core share a process, +// so the wire is a plain in-memory pipe. + +import type { WireProvider } from "@parity/truapi-host"; + +/** The callbacks the core hands its frames to. */ +export interface CoreWireCallbacks { + emitFrame(frame: Uint8Array): void; + dispose(): void; +} + +/** The slice of a product core the loopback needs. */ +export interface FrameReceiver { + receiveFrame(frame: Uint8Array): Promise; +} + +/** + * Wire a product core to a `WireProvider` over an in-process pipe. + * + * `receiveFrame` is invoked SYNCHRONOUSLY per posted frame so frames enter the + * core in post order (the tier-2 spike measured a pipe with its own queueing + * manufacturing the exact operation-ordering inversion under test). Its + * returned promise is only observed for errors. A frame like `requestLogin` + * can stay pending for minutes, so completion must not gate later frames. + */ +export function createLoopbackProvider( + makeCore: (callbacks: CoreWireCallbacks) => C, + options: { onReceiveError?: (error: unknown) => void } = {}, +): { provider: WireProvider; core: C } { + const subscribers = new Set<(frame: Uint8Array) => void>(); + const core = makeCore({ + emitFrame(frame) { + for (const callback of [...subscribers]) { + callback(frame); + } + }, + dispose() {}, + }); + const provider: WireProvider = { + postMessage(frame: Uint8Array): void { + core.receiveFrame(frame).catch((error: unknown) => { + options.onReceiveError?.(error); + }); + }, + subscribe(callback) { + subscribers.add(callback); + return () => subscribers.delete(callback); + }, + dispose() { + subscribers.clear(); + }, + }; + return { provider, core }; +} diff --git a/packages/host-cli/src/operation-order.ts b/packages/host-cli/src/operation-order.ts new file mode 100644 index 00000000..935ff432 --- /dev/null +++ b/packages/host-cli/src/operation-order.ts @@ -0,0 +1,159 @@ +// Copyright 2026 Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: AGPL-3.0-only + +// WORKAROUND for a causal-ordering hazard in the chain-head relay, measured +// to SURVIVE the Rust core (its TS-host-era header blamed a package that is +// deleted, yet the identical inversion reproduces). +// +// papi learns a chain-head operation's `operationId` ONLY from the +// `chainHead_v1_storage`/`_body`/`_call` start-response +// (`{result:{result:"started",operationId}}`): substrate-client registers the +// per-operation subscriber inside that response's `onSuccess`, then routes +// events through a manager whose dispatch is +// +// next(id, data) { subscriptions.get(id)?.next(data) } +// +// so an operation event that arrives BEFORE its start-response is dropped on +// the floor (silently, thanks to the `?.`) and the read it belongs to never +// settles. Measured against the core (tier 8): papi hung 3 runs in 6 with a +// 155-request/12s retry storm, and inversion count correlated with the hang +// 6/6. With this shim wrapping the provider: 6/6 clean. +// +// The shim restores the invariant on the consumer side, where it is cheap and +// transport-agnostic: hold back events whose `operationId` has not been +// announced yet, and release them the moment it is. It belongs on the PRODUCT +// side, wrapping the papi `JsonRpcProvider` that speaks to the core. + +/** + * Structurally compatible with polkadot-api's `JsonRpcProvider` (which passes + * parsed message objects), without making papi a dependency of this package. + */ +export type JsonRpcProvider = (onMessage: (message: unknown) => void) => { + send: (message: unknown) => void; + disconnect: () => void; +}; + +type RpcMessage = unknown; + +/** Operation events after which an `operationId` is retired by the spec. */ +const TERMINAL_EVENTS = new Set([ + "operationBodyDone", + "operationCallDone", + "operationStorageDone", + "operationError", + "operationInaccessible", +]); + +/** The `operationId` a start-response announces, if this message is one. */ +function announcedOperationId(message: RpcMessage): string | undefined { + const result = (message as { result?: unknown }).result; + if (result === null || typeof result !== "object") return undefined; + const started = result as { result?: unknown; operationId?: unknown }; + return started.result === "started" && typeof started.operationId === "string" + ? started.operationId + : undefined; +} + +/** The follow-event payload, if this message is a `chainHead_v1_followEvent`. */ +function followEvent( + message: RpcMessage, +): { event?: unknown; operationId?: unknown } | undefined { + if ((message as { method?: unknown }).method !== "chainHead_v1_followEvent") { + return undefined; + } + return ( + message as { + params?: { result?: { event?: unknown; operationId?: unknown } }; + } + ).params?.result; +} + +/** + * Wrap a `JsonRpcProvider` so a chain-head operation's events can never reach + * the consumer before the start-response that names the operation. Messages + * without an `operationId` (`initialized`, `newBlock`, `finalized`, plain + * responses) pass straight through, so ordering is only ever adjusted where + * it is load-bearing. + */ +export function serializeOperationStarts( + provider: JsonRpcProvider, +): JsonRpcProvider { + return (onMessage) => { + const announced = new Set(); + const queued = new Map(); + // Whether a follow is currently live. After a `stop`, papi's + // substrate-client has torn the follow down and rejects any operation it + // is told about (`onSubscribeOperation` errors with DisjointError while + // followSubscription is null), so a start-response still in flight from + // the dead follow registers NO subscriber. Announcing its id anyway would + // un-gate the NEXT operation that reuses that number (substrate hands + // out small per-follow counters, so the refollow's first storage op is + // very likely to be "1" again) and papi would silently drop its events. + // That is precisely the stall this shim exists to prevent. `initialized` + // is the first event of every follow, so it marks the point from which + // start-responses can be trusted again. Starts true: the opening follow + // has not been stopped. + let followLive = true; + + return provider((message) => { + const startedId = announcedOperationId(message); + if (startedId !== undefined) { + // Forward it regardless (papi decides what to do with it), but only + // treat it as an announcement while a follow is live. + onMessage(message); + if (!followLive) return; + announced.add(startedId); + const pending = queued.get(startedId); + if (pending !== undefined) { + queued.delete(startedId); + for (const event of pending) onMessage(event); + } + return; + } + + const event = followEvent(message); + if (event === undefined) { + onMessage(message); + return; + } + + // A `stop` ends the follow, so every operation under it is dead: drop + // the bookkeeping (and any still-unannounced events, which can never be + // delivered) so a refollow starts clean. + if (event.event === "stop") { + followLive = false; + announced.clear(); + queued.clear(); + onMessage(message); + return; + } + + const operationId = event.operationId; + if (typeof operationId !== "string") { + // Any non-operation follow event (`initialized`, `newBlock`, + // `bestBlockChanged`, `finalized`) can only come from a LIVE follow, + // so it ends the post-stop dead zone. Keyed on the whole class rather + // than `initialized` alone: if a refollow's `initialized` were ever + // missed, start-responses would stop being honoured and every + // operation would queue forever, trading one hang for another. + followLive = true; + onMessage(message); + return; + } + + if (!announced.has(operationId)) { + const pending = queued.get(operationId); + if (pending === undefined) queued.set(operationId, [message]); + else pending.push(message); + return; + } + + onMessage(message); + // Retiring the id on its terminal event keeps both maps bounded over a + // long-lived client instead of growing once per operation. + if (typeof event.event === "string" && TERMINAL_EVENTS.has(event.event)) { + announced.delete(operationId); + } + }); + }; +} diff --git a/packages/host-cli/src/presenter.ts b/packages/host-cli/src/presenter.ts new file mode 100644 index 00000000..1b867c23 --- /dev/null +++ b/packages/host-cli/src/presenter.ts @@ -0,0 +1,183 @@ +// Copyright 2026 Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: AGPL-3.0-only + +// The terminal presentation layer. A host IS the UI layer: the web host +// answers the core with modals, this one answers with a QR and readline +// prompts. Everything is swappable behind `HostPresenter` so an embedding CLI +// can restyle without re-wiring callbacks (and tests can script decisions). + +import * as readline from "node:readline/promises"; +import type { AuthState } from "@parity/truapi-host"; +import { shortHex } from "./hex.js"; +import { renderQrTerminal } from "./qr.js"; +import type { ConfirmRequest } from "./reviews.js"; + +export interface HostPresenter { + /** Render the core-owned auth lifecycle (QR, progress, session identity). */ + authStateChanged(state: AuthState): void; + /** Ask the user to approve a reviewed action or permission. */ + confirm(request: ConfirmRequest): Promise; + /** Show a product notification. */ + notify(text: string): void; + /** Hand a URL to the user (the CLI cannot assume a system browser). */ + openUrl(url: string): void; + dispose(): void; +} + +export interface TerminalPresenterOptions { + /** Where to render. Defaults to stderr so piped stdout stays clean. */ + output?: NodeJS.WriteStream; + /** Where confirm prompts read from. Defaults to stdin. */ + input?: NodeJS.ReadStream; + /** Override the QR renderer (tests, exotic terminals). */ + renderQr?: (deeplink: string) => Promise; +} + +export function createTerminalPresenter( + options: TerminalPresenterOptions = {}, +): HostPresenter { + const output = options.output ?? process.stderr; + const input = options.input ?? process.stdin; + const renderQr = options.renderQr ?? renderQrTerminal; + const write = (text: string): void => { + output.write(text); + }; + + let progressTimer: NodeJS.Timeout | null = null; + let disposed = false; + // Confirm prompts share one stdin. Serialize them so two overlapping + // reviews can never interleave their answers. + let promptChain: Promise = Promise.resolve(); + + const clearProgress = (): void => { + if (progressTimer !== null) { + clearInterval(progressTimer); + progressTimer = null; + if (output.isTTY) { + write("\n"); + } + } + }; + + const startProgress = (label: string): void => { + clearProgress(); + const startedAt = Date.now(); + if (!output.isTTY) { + write(`${label}\n`); + return; + } + write(label); + progressTimer = setInterval(() => { + const elapsed = Math.round((Date.now() - startedAt) / 1000); + write(`\r${label} ${String(elapsed)}s`); + }, 1000); + // A progress line must never be the thing keeping the process alive. + progressTimer.unref(); + }; + + return { + authStateChanged(state) { + if (disposed) { + return; + } + switch (state.tag) { + case "Pairing": { + const { deeplink } = state.value; + void renderQr(deeplink).then( + (qr) => { + if (disposed) { + return; + } + write( + `\nScan with the Polkadot app to sign in:\n\n${qr}\n` + + `Or open this link on your phone:\n ${deeplink}\n\n`, + ); + }, + () => { + // A QR that fails to render must not hide the deeplink. + write( + `\nOpen this link on your phone to sign in:\n ${deeplink}\n\n`, + ); + }, + ); + break; + } + case "Authenticating": + // The People-chain statement round-trip runs ~20s with no further + // callback. Without this line the host looks hung on a stale QR. + startProgress( + "Confirmed on your phone. Completing sign-in (about 20 seconds)…", + ); + break; + case "Connected": { + clearProgress(); + const { publicKey, fullUsername, liteUsername } = state.value; + const username = fullUsername ?? liteUsername; + write( + `Signed in${username !== undefined ? ` as ${username}` : ""} (${shortHex(publicKey)}).\n`, + ); + break; + } + case "LoginFailed": + clearProgress(); + write(`Sign-in failed: ${state.value.reason}\n`); + break; + case "Disconnected": + clearProgress(); + write("Signed out.\n"); + break; + } + }, + + confirm(request) { + const decision = promptChain.then(async () => { + if (disposed) { + return false; + } + clearProgress(); + const lines = [ + "", + `▸ ${request.title}`, + ...request.details.map((detail) => ` ${detail}`), + ]; + if (request.phoneVerifies) { + lines.push( + " Verify the full details in the Polkadot app on your phone.", + " Nothing is signed until you approve it there.", + ); + } + write(`${lines.join("\n")}\n`); + if (!input.isTTY) { + // A host that cannot ask must not approve. + write(" No interactive terminal, denying automatically.\n"); + return false; + } + const rl = readline.createInterface({ input, output }); + try { + const answer = await rl.question(" Continue? [y/N] "); + return /^y(es)?$/i.test(answer.trim()); + } finally { + rl.close(); + } + }); + promptChain = decision.then( + () => {}, + () => {}, + ); + return decision; + }, + + notify(text) { + write(`• ${text}\n`); + }, + + openUrl(url) { + write(`Open this link in your browser:\n ${url}\n`); + }, + + dispose() { + disposed = true; + clearProgress(); + }, + }; +} diff --git a/packages/host-cli/src/qr.ts b/packages/host-cli/src/qr.ts new file mode 100644 index 00000000..82877ca3 --- /dev/null +++ b/packages/host-cli/src/qr.ts @@ -0,0 +1,13 @@ +// Copyright 2026 Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: AGPL-3.0-only + +import * as QRCode from "qrcode"; + +/** + * Render a pairing deeplink as a scannable terminal QR (ANSI half-blocks). + * The deeplink is produced by the core with no network at all, so this can be + * drawn instantly and offline. + */ +export function renderQrTerminal(text: string): Promise { + return QRCode.toString(text, { type: "terminal", small: true }); +} diff --git a/packages/host-cli/src/reviews.ts b/packages/host-cli/src/reviews.ts new file mode 100644 index 00000000..78d29dbf --- /dev/null +++ b/packages/host-cli/src/reviews.ts @@ -0,0 +1,149 @@ +// Copyright 2026 Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: AGPL-3.0-only + +// Confirm-prompt content for the core's review surface. +// +// The prompts are DELIBERATELY modest. The host cannot decode what it is +// approving: `CreateTransaction` carries `callData` as opaque hex without a +// method name, a readable address, or arguments, and `PreimageSubmit` +// carries only a byte count. The paired wallet is the authoritative trust +// surface. It decodes and displays the real content before anything is +// signed. So `confirmUserAction` is a local pre-confirmation, and these +// prompts render only what the host knows on TYPED authority (review kind, +// account, chain, sizes) and defer the content to the phone rather than +// pretending to summarise bytes they cannot decode. + +import type { UserConfirmationReview } from "@parity/truapi-host"; +import type { ChainEndpoints } from "./chain-pool.js"; +import { hexByteLength } from "./hex.js"; + +/** What a presenter is asked to confirm. */ +export interface ConfirmRequest { + /** One-line action statement, e.g. `Sign a message`. */ + title: string; + /** Typed metadata the host knows on its own authority. */ + details: string[]; + /** + * Whether the paired wallet will show the authoritative content before + * signing. When true, presenters should tell the user to check the phone. + */ + phoneVerifies: boolean; +} + +function chainName( + endpoints: ChainEndpoints | undefined, + genesisHash: string, +): string { + const name = endpoints?.[genesisHash]?.name; + return name !== undefined ? name : `chain ${genesisHash.slice(0, 10)}…`; +} + +function accountLine(account: { + dotNsIdentifier: string; + derivationIndex: number; +}): string { + return `account: ${account.dotNsIdentifier} (derivation #${String(account.derivationIndex)})`; +} + +/** The wallet decodes legacy-account requests too. The host knows even less. */ +function legacyAccountReview(title: string): ConfirmRequest { + return { title, details: [], phoneVerifies: true }; +} + +/** + * Describe a review for a terminal confirm prompt. + * + * `endpoints` (optional) resolves genesis hashes to human-readable chain + * names in transaction prompts. + */ +export function describeReview( + review: UserConfirmationReview, + options: { endpoints?: ChainEndpoints } = {}, +): ConfirmRequest { + switch (review.tag) { + case "SignRaw": { + if (review.value.tag !== "Product") { + return legacyAccountReview("Sign a message with a legacy account"); + } + const { account, payload } = review.value.value; + // The RawPayload discriminant survives into the review: `Bytes` is + // raw binary, `Payload` is a wrapped string message. Rendering the + // distinction matters. Raw bytes could be anything, including a + // transaction-shaped payload. + const kind = + payload.tag === "Bytes" + ? `raw binary data (${String(hexByteLength(payload.value.bytes))} bytes)` + : "a text message"; + return { + title: "Sign a message", + details: [accountLine(account), `payload: ${kind}`], + phoneVerifies: true, + }; + } + case "SignPayload": { + if (review.value.tag !== "Product") { + return legacyAccountReview("Sign a payload with a legacy account"); + } + return { title: "Sign a payload", details: [], phoneVerifies: true }; + } + case "CreateTransaction": { + if (review.value.tag !== "Product") { + return legacyAccountReview( + "Submit a transaction with a legacy account", + ); + } + const { signer, genesisHash, callData } = review.value.value; + return { + title: `Submit a transaction on ${chainName(options.endpoints, genesisHash)}`, + details: [ + accountLine(signer), + `call data: ${String(hexByteLength(callData))} bytes (not decodable here)`, + ], + phoneVerifies: true, + }; + } + case "ResourceAllocation": + return { + title: "Allocate network resources", + details: review.value.resources.map( + (resource) => `resource: ${JSON.stringify(resource)}`, + ), + phoneVerifies: true, + }; + case "PreimageSubmit": + return { + title: "Publish data to the Bulletin chain", + details: [ + `size: ${String(review.value.size)} bytes (content not decodable here)`, + ], + phoneVerifies: true, + }; + case "AccountAlias": + return { + title: "Derive a contextual alias", + details: [`product: ${review.value.callingProductId}`], + phoneVerifies: false, + }; + case "CreateProof": + return { + title: "Create a ring-VRF proof", + details: [`product: ${review.value.callingProductId}`], + phoneVerifies: false, + }; + case "IdentityDisclosure": + return { + title: "Disclose your primary identity to a product", + details: [`product: ${review.value.productId}`], + phoneVerifies: false, + }; + case "AccountAccess": + return { + title: "Allow one product to access another product's account", + details: [ + `requesting: ${review.value.requestingProductId}`, + `target: ${review.value.targetProductId}`, + ], + phoneVerifies: false, + }; + } +} diff --git a/packages/host-cli/src/wasm.ts b/packages/host-cli/src/wasm.ts new file mode 100644 index 00000000..cf1ff1f2 --- /dev/null +++ b/packages/host-cli/src/wasm.ts @@ -0,0 +1,103 @@ +// Copyright 2026 Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: AGPL-3.0-only + +// Boots the Rust core under plain node: no Worker, no DOM, no fetch. +// +// `@parity/truapi-host` ships a `wasm-bindgen --target web` build, but the +// browser coupling lives in the wrappers, not the core. `initSync({module})` +// instantiates the wasm from a plain Buffer, and the generated typed-to-raw +// callback adapter (`createWasmRawCallbacks`) references nothing beyond plain +// JS. The adapter is NOT in the package's exports map, so it is reached by +// file URL relative to the one wasm path that IS exported. This is an +// upstream packaging ask: a `./node` export would delete this file's second +// half. + +import { existsSync, readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import type { RequiredHostCallbacks } from "@parity/truapi-host"; + +type WasmBindings = typeof import("@parity/truapi-host/wasm/web"); + +export interface WasmCore { + bindings: WasmBindings; + /** The package's own generated typed-to-raw adapter, reached by file URL. */ + createRawCallbacks: (callbacks: RequiredHostCallbacks) => unknown; +} + +/** + * Locate `@parity/truapi-host`'s install directory. `import.meta.resolve` is + * the honest way. The node_modules walk covers runtimes that transform + * modules and lose it (vitest's vite-node, some bundlers). + */ +function findHostPackageDir(): string { + try { + const glue = fileURLToPath( + import.meta.resolve("@parity/truapi-host/wasm/web"), + ); + // dist/wasm/web/truapi_server.js -> package root + return dirname(dirname(dirname(dirname(glue)))); + } catch { + let dir = dirname(fileURLToPath(import.meta.url)); + for (;;) { + const candidate = join(dir, "node_modules", "@parity", "truapi-host"); + if (existsSync(join(candidate, "package.json"))) { + return candidate; + } + const parent = dirname(dir); + if (parent === dir) { + throw new Error( + "Could not locate @parity/truapi-host. Pass `wasmDir` (its dist/ directory) to createCliHost.", + ); + } + dir = parent; + } + } +} + +// Keyed by the resolved dist directory, not held as one process-wide +// singleton: a caller passing a different `wasmDir` gets its own core +// instance (module identity follows the file URL), instead of silently +// receiving whichever core loaded first. +const loadedByDistDir = new Map>(); + +async function loadFrom(distDir: string): Promise { + const glueUrl = pathToFileURL( + join(distDir, "wasm", "web", "truapi_server.js"), + ).href; + const bindings = (await import(glueUrl)) as WasmBindings; + bindings.initSync({ + module: readFileSync(join(distDir, "wasm", "web", "truapi_server_bg.wasm")), + }); + const adapterUrl = pathToFileURL( + join(distDir, "generated", "host-callbacks-adapter.js"), + ).href; + const adapter = (await import(adapterUrl)) as { + createWasmRawCallbacks: (callbacks: RequiredHostCallbacks) => unknown; + }; + return { bindings, createRawCallbacks: adapter.createWasmRawCallbacks }; +} + +/** + * Load and instantiate the wasm core, once per dist directory. + * + * The wasm holds module-level state, so everything sharing a dist directory + * must go through ONE instance. The glue is always imported by file URL to + * keep module identity stable (a bare-specifier import elsewhere would + * materialize a second instance). + */ +export function loadWasmCore( + options: { wasmDir?: string } = {}, +): Promise { + const distDir = options.wasmDir ?? join(findHostPackageDir(), "dist"); + let pending = loadedByDistDir.get(distDir); + if (pending === undefined) { + pending = loadFrom(distDir); + loadedByDistDir.set(distDir, pending); + // A failed load (wrong wasmDir) must not cache its rejection forever. + pending.catch(() => { + loadedByDistDir.delete(distDir); + }); + } + return pending; +} diff --git a/packages/host-cli/tests/chain-pool.test.ts b/packages/host-cli/tests/chain-pool.test.ts new file mode 100644 index 00000000..07719da6 --- /dev/null +++ b/packages/host-cli/tests/chain-pool.test.ts @@ -0,0 +1,220 @@ +// Copyright 2026 Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: AGPL-3.0-only + +import { describe, it, expect } from "vitest"; +import { createChainPool, type SocketLike } from "../src/chain-pool.js"; + +const GENESIS = + "0xc5af1826b31493f08b7e2a823842f98575b806a784126f28da9608c68665afa5"; + +class FakeSocket implements SocketLike { + listeners = new Map void)[]>(); + sent: string[] = []; + closed = false; + + addEventListener(type: string, listener: (event: unknown) => void): void { + const existing = this.listeners.get(type) ?? []; + this.listeners.set(type, [...existing, listener]); + } + + send(data: string): void { + this.sent.push(data); + } + + close(): void { + this.closed = true; + } + + emit(type: string, event: unknown = {}): void { + for (const listener of this.listeners.get(type) ?? []) { + listener(event); + } + } + + receive(message: unknown): void { + this.emit("message", { data: JSON.stringify(message) }); + } +} + +function pool(maxLeasesPerSocket = 2) { + const sockets: FakeSocket[] = []; + const chainPool = createChainPool({ + endpoints: { [GENESIS]: { rpc: "wss://example.test", name: "people" } }, + maxLeasesPerSocket, + createSocket: () => { + const socket = new FakeSocket(); + sockets.push(socket); + return socket; + }, + }); + return { chainPool, sockets }; +} + +/** Collect everything a connection's responses() stream has delivered. */ +function collect(connection: { responses(): AsyncIterable }) { + const received: unknown[] = []; + void (async () => { + for await (const json of connection.responses()) { + received.push(JSON.parse(json)); + } + })(); + return received; +} + +const tick = () => new Promise((resolve) => setTimeout(resolve, 0)); + +describe("createChainPool", () => { + it("As the wasm core, I connect to a chain with no configured endpoint and the connection is rejected", async () => { + // Given + const { chainPool } = pool(); + + // Then + await expect(chainPool.connect("0xdeadbeef")).rejects.toThrow( + /no RPC endpoint/, + ); + }); + + it("As the wasm core, I send on two leases of one shared socket and each receives exactly its own response", async () => { + // Given + const { chainPool, sockets } = pool(); + const a = await chainPool.connect(GENESIS); + const b = await chainPool.connect(GENESIS); + expect(sockets).toHaveLength(1); + const socket = sockets[0]; + socket.emit("open"); + + // When + // Both leases use the same request id. The pool must keep them apart. + a.send(JSON.stringify({ jsonrpc: "2.0", id: "p:1", method: "m_a" })); + b.send(JSON.stringify({ jsonrpc: "2.0", id: "p:1", method: "m_b" })); + const [wireA, wireB] = socket.sent.map( + (json) => JSON.parse(json) as { id: string }, + ); + expect(wireA.id).not.toBe(wireB.id); + + const seenA = collect(a); + const seenB = collect(b); + socket.receive({ jsonrpc: "2.0", id: wireB.id, result: "for-b" }); + socket.receive({ jsonrpc: "2.0", id: wireA.id, result: "for-a" }); + await tick(); + + // Then + // Each lease got exactly its own response, with the ORIGINAL id restored. + expect(seenA).toEqual([{ jsonrpc: "2.0", id: "p:1", result: "for-a" }]); + expect(seenB).toEqual([{ jsonrpc: "2.0", id: "p:1", result: "for-b" }]); + }); + + it("As the wasm core, I subscribe on one lease and its notifications reach only that lease", async () => { + // Given + const { chainPool, sockets } = pool(); + const a = await chainPool.connect(GENESIS); + const b = await chainPool.connect(GENESIS); + const socket = sockets[0]; + socket.emit("open"); + + // When + a.send( + JSON.stringify({ jsonrpc: "2.0", id: 1, method: "chainHead_v1_follow" }), + ); + const wireId = (JSON.parse(socket.sent[0]) as { id: string }).id; + const seenA = collect(a); + const seenB = collect(b); + // The subscribe response names the token (a string result)... + socket.receive({ jsonrpc: "2.0", id: wireId, result: "sub-token" }); + // ...and notifications for it must reach ONLY the subscribing lease. + socket.receive({ + jsonrpc: "2.0", + method: "chainHead_v1_followEvent", + params: { subscription: "sub-token", result: { event: "initialized" } }, + }); + await tick(); + + // Then + expect(seenA).toHaveLength(2); + expect(seenB).toHaveLength(0); + }); + + it("As the wasm core, I connect past the lease cap and the pool opens another socket", async () => { + // Given + const { chainPool, sockets } = pool(2); + await chainPool.connect(GENESIS); + await chainPool.connect(GENESIS); + + // When + await chainPool.connect(GENESIS); + + // Then + expect(sockets).toHaveLength(2); + expect(chainPool.socketCounts()).toEqual({ "wss://example.test": 2 }); + }); + + it("As the wasm core, I send before the socket opens and the message flushes on open", async () => { + // Given + const { chainPool, sockets } = pool(); + const lease = await chainPool.connect(GENESIS); + lease.send(JSON.stringify({ jsonrpc: "2.0", id: 1, method: "early" })); + expect(sockets[0].sent).toHaveLength(0); + + // When + sockets[0].emit("open"); + + // Then + expect(sockets[0].sent).toHaveLength(1); + }); + + it("As a host embedder, a freed lease slot is reused and the socket stays open while any lease remains", async () => { + // Given + const { chainPool, sockets } = pool(2); + const a = await chainPool.connect(GENESIS); + const b = await chainPool.connect(GENESIS); + + // When + a.close(); + + // Then + expect(sockets[0].closed).toBe(false); + + // The freed slot is reused instead of opening socket #2. + await chainPool.connect(GENESIS); + expect(sockets).toHaveLength(1); + + b.close(); + // One lease (the reused slot) still holds it open. + expect(sockets[0].closed).toBe(false); + }); + + it("As the wasm core, my lease's response stream ends when the socket closes", async () => { + // Given + const { chainPool, sockets } = pool(); + const lease = await chainPool.connect(GENESIS); + const iterator = lease.responses()[Symbol.asyncIterator](); + const first = iterator.next(); + + // When + sockets[0].emit("close"); + + // Then + expect((await first).done).toBe(true); + expect(chainPool.socketCounts()).toEqual({}); + }); + + it("As the wasm core, responses for a closed lease are dropped instead of leaking to other leases", async () => { + // Given + const { chainPool, sockets } = pool(); + const a = await chainPool.connect(GENESIS); + const b = await chainPool.connect(GENESIS); + const socket = sockets[0]; + socket.emit("open"); + a.send(JSON.stringify({ jsonrpc: "2.0", id: 1, method: "m" })); + const wireId = (JSON.parse(socket.sent[0]) as { id: string }).id; + const seenB = collect(b); + + // When + a.close(); + socket.receive({ jsonrpc: "2.0", id: wireId, result: "late" }); + await tick(); + + // Then + expect(seenB).toHaveLength(0); + }); +}); diff --git a/packages/host-cli/tests/errors.test.ts b/packages/host-cli/tests/errors.test.ts new file mode 100644 index 00000000..7996036e --- /dev/null +++ b/packages/host-cli/tests/errors.test.ts @@ -0,0 +1,54 @@ +// Copyright 2026 Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: AGPL-3.0-only + +import { describe, it, expect } from "vitest"; +import { explainProductError, isProbableSsoTimeout } from "../src/errors.js"; + +describe("isProbableSsoTimeout", () => { + it("As a host embedder, the bare TxError the 180s SSO timeout produces is recognized as one", () => { + // Then + expect( + isProbableSsoTimeout({ isSdkError: true, source: "tx", name: "TxError" }), + ).toBe(true); + }); + + it("As a host embedder, a TxError carrying a real message is never flagged as an SSO timeout", () => { + // Then + expect( + isProbableSsoTimeout({ + isSdkError: true, + source: "tx", + name: "TxError", + message: "insufficient funds", + }), + ).toBe(false); + }); + + it("As a host embedder, unrelated errors are never flagged as SSO timeouts", () => { + // Then + expect(isProbableSsoTimeout(new Error("boom"))).toBe(false); + expect(isProbableSsoTimeout(undefined)).toBe(false); + expect( + isProbableSsoTimeout({ isSdkError: true, source: "rpc", name: "X" }), + ).toBe(false); + }); +}); + +describe("explainProductError", () => { + it("As a CLI user, I hit the probable SSO timeout and get phone-facing guidance", () => { + // When + const explained = explainProductError({ + isSdkError: true, + source: "tx", + name: "TxError", + }); + + // Then + expect(explained).toMatch(/no response from your phone/i); + }); + + it("As a CLI user, the host stays silent for errors it cannot improve on", () => { + // Then + expect(explainProductError(new Error("boom"))).toBeUndefined(); + }); +}); diff --git a/packages/host-cli/tests/host.test.ts b/packages/host-cli/tests/host.test.ts new file mode 100644 index 00000000..b897039a --- /dev/null +++ b/packages/host-cli/tests/host.test.ts @@ -0,0 +1,166 @@ +// Copyright 2026 Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: AGPL-3.0-only + +// Integration: the REAL wasm core, booted in-process, driven by the real +// product client over the loopback wire. No network, no phone: +// +// - a product localStorage round-trip exercises the full frame path +// (client -> transport -> loopback -> Rust -> storage callbacks -> back). +// - `requestLogin` exercises the pairing presentation headless. The core +// emits `AuthState.Pairing{deeplink}` before opening any socket, so a +// host with NO endpoints still gets a QR to render (Gate A's shape). +// - logout clearing pins the product-storage-is-not-account-scoped rule. + +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { mkdtemp, readFile, rm, stat } from "node:fs/promises"; +import { existsSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createClient, createTransport } from "@parity/truapi"; +import type { AuthState } from "@parity/truapi-host"; + +import { createCliHost, type CliHost } from "../src/host.js"; +import type { ConfirmRequest } from "../src/reviews.js"; +import type { HostPresenter } from "../src/presenter.js"; + +/** A presenter with scripted decisions and a full recording. */ +function createScriptedPresenter(decide: (request: ConfirmRequest) => boolean) { + const states: AuthState[] = []; + const confirms: ConfirmRequest[] = []; + const notifications: string[] = []; + const presenter: HostPresenter = { + authStateChanged(state) { + states.push(state); + }, + async confirm(request) { + confirms.push(request); + return decide(request); + }, + notify(text) { + notifications.push(text); + }, + openUrl() {}, + dispose() {}, + }; + return { presenter, states, confirms, notifications }; +} + +describe("createCliHost against the real wasm core", () => { + let dir: string; + let host: CliHost; + let scripted: ReturnType; + + beforeAll(async () => { + dir = await mkdtemp(join(tmpdir(), "host-cli-int-")); + scripted = createScriptedPresenter(() => false); + host = await createCliHost({ + host: { name: "host-cli tests", version: "0.0.0-test" }, + pairing: { deeplinkScheme: "polkadotapp" }, + // Unreachable genesis hashes: the test must stay offline. featureSupported + // and chain.connect both answer from this map. + people: { genesisHash: `0x${"11".repeat(32)}` }, + bulletin: { genesisHash: `0x${"22".repeat(32)}` }, + chains: {}, + storageDir: dir, + presenter: scripted.presenter, + logLevel: "off", + }); + }); + + afterAll(async () => { + host.dispose(); + await rm(dir, { recursive: true, force: true }); + }); + + it("As a product, I write and read localStorage and the value round-trips through the Rust core", async () => { + // Given + const product = host.createProduct({ productId: "host-cli-test.dot" }); + const client = createClient(createTransport(product.provider)); + + // When + const written = await client.localStorage.write({ + key: "cache", + value: "0xdeadbeef", + }); + expect(written.isOk()).toBe(true); + + const read = await client.localStorage.read({ key: "cache" }); + + // Then + expect(read.isOk()).toBe(true); + expect(JSON.stringify(read.isOk() ? read.value : null)).toContain( + "deadbeef", + ); + + // The backing file exists, is owner-only, and carries the core's own + // product-storage namespace (product id, NO account component). That + // missing account component is why the host clears it on logout. + const mode = (await stat(host.storagePaths.product)).mode & 0o777; + expect(mode.toString(8)).toBe("600"); + const persisted = JSON.parse( + await readFile(host.storagePaths.product, "utf8"), + ) as Record; + const keys = Object.keys(persisted); + expect(keys.length).toBeGreaterThan(0); + for (const key of keys) { + expect(key).toMatch(/^truapi:product-storage:v1:.*host-cli-test\.dot/); + } + + product.dispose(); + }); + + it("As a host embedder, I request a login offline and receive a Pairing deeplink to render headless", async () => { + // Given + const product = host.createProduct({ productId: "host-cli-test.dot" }); + const client = createClient(createTransport(product.provider)); + + // When + // Do NOT await: requestLogin resolves only at a terminal state. The + // pairing presentation is the host's job and arrives via AuthState. + const login = client.account + .requestLogin({ reason: "host-cli integration test" }) + .then( + () => undefined, + () => undefined, + ); + + // Then + const pairing = await host.waitForAuthState( + (state) => state.tag === "Pairing", + 15_000, + ); + if (pairing.tag !== "Pairing") { + throw new Error("unreachable"); + } + expect(pairing.value.deeplink).toMatch(/^polkadotapp:\/\//); + expect(host.authState()?.tag).toBe("Pairing"); + + // The scripted presenter saw the same state. That is the QR hook. + expect(scripted.states.some((state) => state.tag === "Pairing")).toBe(true); + + host.cancelPairing(); + await host.waitForAuthState( + (state) => state.tag === "Disconnected" || state.tag === "LoginFailed", + 15_000, + ); + await login; + product.dispose(); + }); + + it("As a CLI user, I log out and the host clears product storage", async () => { + // Given + const product = host.createProduct({ productId: "host-cli-test.dot" }); + const client = createClient(createTransport(product.provider)); + await client.localStorage.write({ key: "leftover", value: "0x01" }); + expect(existsSync(host.storagePaths.product)).toBe(true); + + // When + await host.disconnectSession(); + + // Then + expect(existsSync(host.storagePaths.product)).toBe(false); + // Core storage (the host's own slots) survives. Only the product side is + // identity-tainted. + product.dispose(); + }); +}); diff --git a/packages/host-cli/tests/kv.test.ts b/packages/host-cli/tests/kv.test.ts new file mode 100644 index 00000000..aedac762 --- /dev/null +++ b/packages/host-cli/tests/kv.test.ts @@ -0,0 +1,186 @@ +// Copyright 2026 Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: AGPL-3.0-only + +// The core routes a product's localStorage into this store, so its contents +// are per-user secrets by default. These tests pin the permissions, because +// the failure is silent: a 0644 file works perfectly and is simply readable +// by every other local user. + +import { describe, it, expect, afterEach } from "vitest"; +import { + mkdtemp, + stat, + chmod, + rm, + readFile, + writeFile, +} from "node:fs/promises"; +import { existsSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { FileKeyValueStore } from "../src/kv.js"; + +const dirs: string[] = []; + +async function storeIn(...segments: string[]) { + const dir = await mkdtemp(join(tmpdir(), "host-cli-kv-")); + dirs.push(dir); + const path = join(dir, ...segments); + return { path, store: new FileKeyValueStore(path) }; +} + +const modeOf = async (path: string): Promise => + ((await stat(path)).mode & 0o777).toString(8); + +afterEach(async () => { + await Promise.all( + dirs.splice(0).map((d) => rm(d, { recursive: true, force: true })), + ); +}); + +describe("FileKeyValueStore", () => { + it("As a CLI user, my host storage file is owner-only", async () => { + // Given + const { path, store } = await storeIn("kv.json"); + + // When + await store.set("dotli:myapp.dot:token", "secret"); + + // Then + expect(await modeOf(path)).toBe("600"); + }); + + it("As a CLI user, a missing parent directory is created owner-only", async () => { + // Given + const { path, store } = await storeIn("nested", "kv.json"); + + // When + await store.set("k", "v"); + + // Then + expect(await modeOf(join(path, ".."))).toBe("700"); + }); + + it("As a CLI user, I upgrade from a build that left the store world-readable and the next write tightens it", async () => { + // Given + const { path, store } = await storeIn("kv.json"); + await store.set("k", "v"); + await chmod(path, 0o644); + + // When + // Any later write must repair it: `mode` on writeFile only applies at + // creation, so without an explicit chmod the file would stay 0644 forever. + await store.set("k2", "v2"); + + // Then + expect(await modeOf(path)).toBe("600"); + }); + + it("As a product, I write keys and can read, list by prefix, and delete them with the file kept in sync", async () => { + // Given + const { path, store } = await storeIn("kv.json"); + + // When + await store.set("dotli:a:one", "1"); + await store.set("dotli:b:two", "2"); + + // Then + expect(await store.get("dotli:a:one")).toBe("1"); + expect(await store.keys("dotli:a:")).toEqual(["dotli:a:one"]); + await store.delete("dotli:a:one"); + expect(await store.get("dotli:a:one")).toBeNull(); + // Persisted, not just cached. + expect(JSON.parse(await readFile(path, "utf8"))).toEqual({ + "dotli:b:two": "2", + }); + }); + + // Regression: serializing only the write chain left the LOAD unguarded, so + // two concurrent set() calls each read the file and the second cache + // assignment discarded the first caller's key. Both writes resolved, yet + // one key vanished. + it("As a product, no key is lost when two writes race", async () => { + // Given + const { path, store } = await storeIn("kv.json"); + // Seed the FILE, not the instance: the race only exists while the lazy + // cache is still unpopulated, so a prior set() through the same store + // would hide it. + await writeFile(path, JSON.stringify({ seed: "1" }), "utf8"); + + // When + await Promise.all([store.set("a", "A"), store.set("b", "B")]); + + // Then + expect(await store.get("a")).toBe("A"); + expect(await store.get("b")).toBe("B"); + expect(JSON.parse(await readFile(path, "utf8"))).toEqual({ + seed: "1", + a: "A", + b: "B", + }); + }); + + it("As a product, no key is lost when a write races a delete", async () => { + // Given + const { path, store } = await storeIn("kv.json"); + await writeFile(path, JSON.stringify({ keep: "1", gone: "2" }), "utf8"); + + // When + await Promise.all([store.set("added", "3"), store.delete("gone")]); + + // Then + expect(await store.get("added")).toBe("3"); + expect(await store.get("gone")).toBeNull(); + expect(await store.get("keep")).toBe("1"); + }); + + it("As a host embedder, the store exposes its file path so I can clear it on logout", async () => { + // Given + const { path, store } = await storeIn("kv.json"); + + // Then + // The core keys product entries by product id, not by account, so a + // consumer that switches identities must be able to find and delete this + // file. + expect(store.filePath).toBe(path); + }); + + it("As a host embedder, I clear the store and both the file and the cached values are gone", async () => { + // Given + const { path, store } = await storeIn("kv.json"); + await store.set("a", "A"); + + // When + await store.clear(); + + // Then + expect(existsSync(path)).toBe(false); + expect(await store.get("a")).toBeNull(); + }); + + it("As a host embedder, no pre-clear data survives a clear() racing a write", async () => { + // Given + const { path, store } = await storeIn("kv.json"); + await store.set("a", "A"); + + // When + // Which side wins is inherently racy. The invariant is that pre-clear + // data is gone from memory AND disk, and memory agrees with disk. + await Promise.all([store.set("b", "B"), store.clear()]); + + // Then + expect(await store.get("a")).toBeNull(); + const inMemoryB = await store.get("b"); + if (existsSync(path)) { + const persisted = JSON.parse(await readFile(path, "utf8")) as Record< + string, + string + >; + expect(persisted.a).toBeUndefined(); + expect(persisted.b ?? null).toBe(inMemoryB); + } else { + expect(inMemoryB).toBeNull(); + } + }); +}); diff --git a/packages/host-cli/tests/operation-order.test.ts b/packages/host-cli/tests/operation-order.test.ts new file mode 100644 index 00000000..af2c0873 --- /dev/null +++ b/packages/host-cli/tests/operation-order.test.ts @@ -0,0 +1,161 @@ +// Copyright 2026 Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: AGPL-3.0-only + +// The shim's contract, measured against the core (tier 8): an operation's +// events must never reach the consumer before the start-response that names +// the operation. papi drops early events silently and the read never +// settles. + +import { describe, it, expect } from "vitest"; +import { + serializeOperationStarts, + type JsonRpcProvider, +} from "../src/operation-order.js"; + +const startResponse = (id: string, operationId: string) => ({ + jsonrpc: "2.0", + id, + result: { result: "started", operationId }, +}); + +const operationEvent = (event: string, operationId: string) => ({ + jsonrpc: "2.0", + method: "chainHead_v1_followEvent", + params: { subscription: "f1", result: { event, operationId } }, +}); + +const followEvent = (event: string) => ({ + jsonrpc: "2.0", + method: "chainHead_v1_followEvent", + params: { subscription: "f1", result: { event } }, +}); + +/** A provider we can push messages through, recording what the consumer saw. */ +function harness() { + let push: (message: unknown) => void = () => {}; + const provider: JsonRpcProvider = (onMessage) => { + push = onMessage; + return { send: () => {}, disconnect: () => {} }; + }; + const seen: unknown[] = []; + serializeOperationStarts(provider)((message) => seen.push(message)); + return { push: (message: unknown) => push(message), seen }; +} + +const eventNames = (seen: unknown[]): string[] => + seen.map((message) => { + const m = message as { + id?: string; + params?: { result?: { event?: string } }; + }; + return m.params?.result?.event ?? `response:${m.id ?? "?"}`; + }); + +describe("serializeOperationStarts", () => { + it("As a papi consumer, already-ordered traffic reaches me untouched", () => { + // Given + const { push, seen } = harness(); + + // When + push(followEvent("initialized")); + push(startResponse("r1", "1")); + push(operationEvent("operationStorageItems", "1")); + push(operationEvent("operationStorageDone", "1")); + + // Then + expect(eventNames(seen)).toEqual([ + "initialized", + "response:r1", + "operationStorageItems", + "operationStorageDone", + ]); + }); + + it("As a papi consumer, I never receive an operation event before the start-response that names it", () => { + // Given + const { push, seen } = harness(); + push(followEvent("initialized")); + // The measured inversion: items and done arrive BEFORE the response that + // names operationId 0. + push(operationEvent("operationStorageItems", "0")); + push(operationEvent("operationStorageDone", "0")); + expect(eventNames(seen)).toEqual(["initialized"]); + + // When + push(startResponse("r1", "0")); + + // Then + expect(eventNames(seen)).toEqual([ + "initialized", + "response:r1", + "operationStorageItems", + "operationStorageDone", + ]); + }); + + it("As a papi consumer, interleaved operations are gated independently", () => { + // Given + const { push, seen } = harness(); + + // When + push(startResponse("r1", "1")); + push(operationEvent("operationStorageItems", "2")); // held: not announced + push(operationEvent("operationStorageItems", "1")); // flows: announced + push(startResponse("r2", "2")); // releases the held event + + // Then + expect(eventNames(seen)).toEqual([ + "response:r1", + "operationStorageItems", + "response:r2", + "operationStorageItems", + ]); + }); + + it("As a papi consumer, a start-response from a stopped follow never releases the refollow's events", () => { + // Given + const { push, seen } = harness(); + push(followEvent("stop")); + // In flight from the dead follow. papi registered no subscriber for it. + push(startResponse("r1", "1")); + // The refollow reuses small operation numbers. Its "1" must NOT be + // un-gated by the dead follow's start-response. + push(operationEvent("operationStorageItems", "1")); + expect(eventNames(seen)).toEqual(["stop", "response:r1"]); + + // When + // The refollow comes alive. Its own start-response releases the event. + push(followEvent("initialized")); + push(startResponse("r2", "1")); + + // Then + expect(eventNames(seen)).toEqual([ + "stop", + "response:r1", + "initialized", + "response:r2", + "operationStorageItems", + ]); + }); + + it("As a papi consumer, a reused operation id is gated again after its terminal event", () => { + // Given + const { push, seen } = harness(); + push(startResponse("r1", "1")); + push(operationEvent("operationStorageDone", "1")); + // A NEW operation reusing the id must wait for its own start-response. + push(operationEvent("operationStorageItems", "1")); + expect(eventNames(seen)).toEqual(["response:r1", "operationStorageDone"]); + + // When + push(startResponse("r2", "1")); + + // Then + expect(eventNames(seen)).toEqual([ + "response:r1", + "operationStorageDone", + "response:r2", + "operationStorageItems", + ]); + }); +}); diff --git a/packages/host-cli/tsconfig.build.json b/packages/host-cli/tsconfig.build.json new file mode 100644 index 00000000..a8d421a4 --- /dev/null +++ b/packages/host-cli/tsconfig.build.json @@ -0,0 +1,12 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"] +} diff --git a/packages/host-cli/tsconfig.json b/packages/host-cli/tsconfig.json new file mode 100644 index 00000000..b8c70cff --- /dev/null +++ b/packages/host-cli/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "@dotli/typescript-config/base.json", + "compilerOptions": { + // This package is PUBLISHED and runs under plain node, so it deviates from + // the app packages: nodenext resolution (relative imports carry a `.js` + // extension), no DOM lib, and node types instead of vite's. + "module": "NodeNext", + "moduleResolution": "NodeNext", + "allowImportingTsExtensions": false, + "lib": ["ES2022"], + "types": ["node"] + }, + "include": ["src", "tests", "examples"] +} diff --git a/packages/host-cli/vitest.config.ts b/packages/host-cli/vitest.config.ts new file mode 100644 index 00000000..a38be654 --- /dev/null +++ b/packages/host-cli/vitest.config.ts @@ -0,0 +1,11 @@ +// Copyright 2026 Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: AGPL-3.0-only + +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + // The integration test boots the real 2 MiB wasm core. + testTimeout: 30_000, + }, +}); diff --git a/scripts/set-version.ts b/scripts/set-version.ts index 700f9697..2516985b 100644 --- a/scripts/set-version.ts +++ b/scripts/set-version.ts @@ -104,10 +104,19 @@ function readVersionedPackages(): PkgVersion[] { const result: PkgVersion[] = []; for (const file of findPackageJsons()) { const text = readFileSync(file, "utf8"); - const pkg = JSON.parse(text) as { version?: unknown }; - if (typeof pkg.version === "string") { - result.push({ file, version: pkg.version, text }); + const pkg = JSON.parse(text) as { version?: unknown; private?: unknown }; + if (typeof pkg.version !== "string") { + continue; + } + // PUBLISHED packages (not `private: true`) version independently: their + // semver tracks their own API and releases via their own tags (e.g. + // `host-cli-v0.1.0`), not the app's release cadence. Syncing them here + // would republish-or-break them on every app release. (The root + // package.json is private and versionless, so it is skipped above.) + if (pkg.private !== true) { + continue; } + result.push({ file, version: pkg.version, text }); } return result; }