@parity/product-sdk-terminal
v0.5.4
Published
QR code login and signing for CLI/terminal apps via Polkadot mobile wallet pairing
Maintainers
Keywords
Readme
@parity/product-sdk-terminal
Migrated from
@polkadot-apps/terminalv0.3.0 (paritytech/polkadot-apps).
QR code login, attestation, and transaction signing for CLI/terminal apps via the Polkadot mobile wallet.
Wraps the @novasamatech/host-papp SDK with Node.js-compatible adapters (file-based storage, WebSocket transport) so the full SSO protocol works outside the browser.
Installation
pnpm add @parity/product-sdk-terminalSetup
Requires Node ≥21. The package relies on the global WebSocket exposed by Node 21+ (via @polkadot-api/[email protected]). On older Node versions the WebSocket connection fails at runtime with WebSocket is not defined.
Note (≥0.3): The
@parity/product-sdk-terminal/registerWASM loader hook has been removed. It existed only to patchverifiablejs(a browser-only inline-WASM dependency of@novasamatech/host-papp). host-papp ≥0.7.9 no longer usesverifiablejs— its sr25519 primitives are pure JS — so no loader hook is needed. Drop any--import @parity/product-sdk-terminal/registerflags from yournode/tsxinvocations.
Quick Start
import { createTerminalAdapter, renderQrCode, waitForSessions } from "@parity/product-sdk-terminal";
// 1. Create the adapter
const adapter = createTerminalAdapter({
appId: "my-terminal-app",
});
// 2. Subscribe to pairing status to show the QR code
adapter.sso.pairingStatus.subscribe(async (status) => {
if (status.step === "pairing") {
console.log(await renderQrCode(status.payload));
console.log("Scan with the Polkadot mobile app...");
}
});
// 3. Authenticate (QR pairing + on-chain attestation)
const result = await adapter.sso.authenticate();
result.match(
(session) => console.log("Logged in!", session?.id),
(error) => console.error("Failed:", error.message),
);
// 4. Wait for sessions to load (they load asynchronously from disk)
const sessions = await waitForSessions(adapter, 2000);
// 5. Sign messages via the paired wallet
if (sessions.length > 0) {
const session = sessions[0];
const signer = createSessionSigner(session, adapter);
// use signer with polkadot-api transactions
}API
createTerminalAdapter(options): TerminalAdapter
Creates a terminal adapter backed by the host-papp SDK.
Options:
appId-- unique app identifier (used as storage namespace)endpoints?-- statement store WebSocket endpoints (defaults to Paseo)hostMetadata?-- optional host environment infostorageDir?-- override the on-disk session directory (defaults to~/.polkadot-apps/). Useful in tests and containerised environments.
Returns a TerminalAdapter with:
appId-- the value you passed in (re-exposed socreateSessionSignercan pull the productId from the adapter)sso-- auth component (.authenticate(),.abortAuthentication(), status subscriptions)sessions-- session manager (signing, disconnect)destroy()-- disconnect the WebSocket and release resources. Idempotent. Suppresses@novasamatech/statement-store's noisyStatement subscription errorlog for ~50 ms after the call.
createSessionSigner(session, adapter): PolkadotSigner
Creates a PolkadotSigner backed by a QR-paired mobile wallet session, using the session's default account (derivationIndex: 0) under the adapter's appId. This is the right entry point for ~all CLI flows.
const [session] = adapter.sessions.sessions.read();
const signer = createSessionSigner(session, adapter);
await contract.publish.tx(domain, cid, { signer, origin });createSessionSignerForAccount(session, ref): PolkadotSigner
Escape hatch for signing as a non-default sub-account of a paired session, or as a productId that differs from the adapter's appId. Most callers don't need this.
ref is { productId: string; derivationIndex: number }:
productId-- dotNS-style identifier of the requesting product. In normal usage this equals the adapter'sappId; pass a different value only if you have an explicit reason.derivationIndex-- BIP32-style child-key index.0is the default account; non-zero indices reach additional sub-accounts derived from the same root.
const subSigner = createSessionSignerForAccount(session, {
productId: "my-product",
derivationIndex: 3,
});Wire format note:
@novasamatech/host-papp0.7 expectsproductAccountId: [productId, derivationIndex]inSigningRawRequest. Both functions above hide that tuple — pass an adapter for the default case or a named-fields object for the escape hatch.
renderQrCode(data, options?): Promise<string>
Render a string as a QR code using Unicode half-block characters for terminal display.
createNodeStorageAdapter(appId, storageDir?): StorageAdapter
File-based storage adapter for Node.js. Data persists in storageDir (defaults to ~/.polkadot-apps/).
waitForSessions(adapter, timeoutMs?): Promise<UserSession[]>
Waits for the session list to emit at least one entry, or resolves with [] after timeoutMs.
Allowance signers — the canonical path
For CLIs that need to write to Bulletin or publish to the Statement Store: ask the paired wallet for an allowance slot, get a PolkadotSigner back, sign extrinsics with it. This is what most consumers want.
import {
createTerminalAdapter,
getBulletinSigner,
getStatementStoreProver,
waitForSessions,
} from "@parity/product-sdk-terminal";
const adapter = createTerminalAdapter({ appId: "my-cli" });
// ...QR pair the phone, wait for the session...
await waitForSessions(adapter);
// First call prompts the wallet for an allowance slot; subsequent calls
// return the cached slot key — no wire round-trip.
const bulletinSigner = await getBulletinSigner(adapter, "my-cli.dot");
await bulletinClient.tx.TransactionStorage.store({ data }).signAndSubmit(bulletinSigner);
// Same idea, returns a StatementProver for `@novasamatech/statement-store` writes.
const prover = await getStatementStoreProver(adapter, "my-cli.dot");getBulletinSigner(adapter, productId, sessionId?): Promise<PolkadotSigner>
getStatementStoreProver(adapter, productId, sessionId?): Promise<StatementProver>
Both default sessionId to the only paired session. With zero or more than one paired sessions and no explicit id, both throw AllowanceError with reason: 'NoSession'. Pass an explicit sessionId to disambiguate.
Failures from the host (Rejected, NotAvailable, codec drift) surface as AllowanceError:
import { AllowanceError } from "@parity/product-sdk-terminal";
try {
const signer = await getBulletinSigner(adapter, "my-cli.dot");
} catch (err) {
if (err instanceof AllowanceError && err.reason === "Rejected") {
// user denied the allowance request on the phone
}
throw err;
}Behind the scenes both helpers wrap adapter.allowance (inherited from host-papp's PappAdapter) and unwrap its neverthrow ResultAsync into a throwing Promise. If you want to keep neverthrow's .match() / .mapErr() ergonomics or need explicit multi-session handling, call adapter.allowance.getBulletinSigner(sessionId, productId) / getStatementStoreProver(...) directly.
Cache-only probes — hasBulletinAllowance / hasStatementStoreAllowance
For paths that must run silently — login health checks, "would calling getBulletinSigner prompt the phone?" decisions, readiness probes — use the cache-only variants. They read host-papp's on-disk allowance file directly and never trigger a wallet prompt.
import {
getBulletinSigner,
hasBulletinAllowance,
} from "@parity/product-sdk-terminal";
if (await hasBulletinAllowance(adapter, "my-cli.dot")) {
// happy path — fetch the signer without risking a wallet prompt
const signer = await getBulletinSigner(adapter, "my-cli.dot");
} else {
// tell the user a wallet prompt will fire, then call getBulletinSigner
console.log("Approve the allowance request on your phone…");
const signer = await getBulletinSigner(adapter, "my-cli.dot");
}hasBulletinAllowance(adapter, productId, sessionId?): Promise<boolean>
hasStatementStoreAllowance(adapter, productId, sessionId?): Promise<boolean>
Resolves true when a slot key for (sessionId, productId, resource) is already cached on disk; false when it is not (file absent, or present with no matching entry). Same sessionId defaulting as the fetching helpers: defaults to the only paired session, throws AllowanceError('NoSession') for zero or multiple paired sessions without an explicit id.
Rejects only on decrypt or decode failures — a corrupted cache file is a real failure, not a "no allowance" signal.
The cache-only check uses an internal mirror of host-papp's
AllowanceRepositorycodec because host-papp doesn't expose a public probe today. We'll switch to the upstream API when one ships; the public surface (hasBulletinAllowance/hasStatementStoreAllowance) won't change.
Allowance management — manual cache + AP control (./host subpath)
For CLIs that need finer control — pre-allocating multiple resources in one wallet prompt, inspecting the cache before round-tripping, or building a signer over a cached key without consulting the wallet — the @parity/product-sdk-terminal/host subpath exposes the lower-level Host-runner surface (the RFC-10 Accounts Protocol companion, an on-disk allowance-key cache, and a local sr25519 signer over the cached keys). Most CLIs don't need this — start with getBulletinSigner / getStatementStoreProver above.
import {
ensureSlotAccountSigner,
requestResourceAllocation,
getCachedAllocation,
createSlotAccountSigner,
} from "@parity/product-sdk-terminal/host";
// First call prompts the wallet; subsequent calls are wire-free.
const signer = await ensureSlotAccountSigner(session, adapter, {
tag: "BulletInAllowance",
value: undefined,
});
await bulletinTx.submitAndWatch(signer);ensureSlotAccountSigner(session, adapter, resource): Promise<PolkadotSigner>
The one call most CLIs want. Cache hit → returns signer; cache miss → allocates via the wallet, then returns signer. Throws on Rejected / NotAvailable. Slot-table resources only (BulletInAllowance, StatementStoreAllowance).
requestResourceAllocation(session, adapter, resources, options?): Promise<ApAllocationOutcome[]>
Lower-level: pre-allocate any combination of resources. Granted keys cached at {storageDir}/{appId}_AllowanceKeys.json (0o600, atomic write). onExisting auto-picks Ignore/Increase based on cache state; override via options.onExisting.
getCachedAllocation(adapter, resource): Promise<CachedAllocation | null>
Read-only cache lookup; no wire round-trip.
createSlotAccountSigner(adapter, resource): Promise<PolkadotSigner | null>
Build a PolkadotSigner from the cached slot key without round-tripping. Returns null if nothing's cached (use ensureSlotAccountSigner for the round-trip-if-missing flow).
Caveats.
AutoSigningcurrently returnsNotAvailableon both Android and iOS wallets. Slot renewal at expiry depends on paritytech/individuality#931 (open) — first-time allocation works end-to-end today.
Migration from @polkadot-apps/terminal
For consumers moving from @polkadot-apps/terminal v0.2.0 / v0.3.0. Existing sessions on disk (~/.polkadot-apps/) carry over — same appId, same path. No re-pairing required for the migration itself.
| Concern | @polkadot-apps/terminal | @parity/product-sdk-terminal |
| --- | --- | --- |
| Package name | @polkadot-apps/terminal | @parity/product-sdk-terminal |
| WASM loader hook | --import @polkadot-apps/terminal/register (required) | removed — no --import needed (host-papp ≥0.7.9 dropped verifiablejs) |
| createTerminalAdapter | async — returned Promise<TerminalAdapter> | sync — returns TerminalAdapter directly. Drop the await. |
| Default account signer | createSessionSigner(session) | createSessionSigner(session, adapter) — pass the adapter as second arg |
| Non-default sub-account signer | not exposed | createSessionSignerForAccount(session, { productId, derivationIndex }) |
| Override session storage dir | not supported (hard-coded ~/.polkadot-apps/) | createTerminalAdapter({ ..., storageDir }) option |
| E2E test helper for sessions | none | createTestSession from @parity/product-sdk-terminal/testing |
| Node version | any (bundled ws) | ≥21 (uses global WebSocket) |
| destroy() shutdown noise | emitted Statement subscription error to stderr | suppressed; console.error muted for ~50 ms |
Why the signer API changed
@novasamatech/host-papp 0.7 replaced SigningRawRequest.address with productAccountId: [productId, derivationIndex]. The wire format requires both fields, so a session-only argument is no longer enough — the signer needs to know which sub-account of which product is asking. We split that into two functions to keep the common case ergonomic:
createSessionSigner(session, adapter)for the default account (uses[adapter.appId, 0])createSessionSignerForAccount(session, { productId, derivationIndex })for everything else
The single-argument createSessionSigner(session) from @polkadot-apps/terminal no longer works against host-papp 0.7 regardless of which package you use.
Migration steps
- Replace the dep:
pnpm remove @polkadot-apps/terminal && pnpm add @parity/product-sdk-terminal - Remove the
--import @.../registerflag from yournode/tsxinvocations orpackage.jsonscripts — the WASM loader hook no longer exists (and is no longer needed). - Drop
awaitin front ofcreateTerminalAdapter(...)calls. - Update each
createSessionSignercall site: changecreateSessionSigner(session)→createSessionSigner(session, adapter). - Verify Node version is ≥21 (
node --version).
If your existing sessions don't appear after migrating, double-check that the appId is identical to what you used in @polkadot-apps/terminal — the on-disk file names depend on it.
Testing
The @parity/product-sdk-terminal/testing subpath exports createTestSession, a helper that synthesizes a valid persisted session on disk. E2E tests can inject a known-good session without going through QR pairing + attestation:
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createTerminalAdapter, waitForSessions } from "@parity/product-sdk-terminal";
import { createTestSession } from "@parity/product-sdk-terminal/testing";
const storageDir = mkdtempSync(join(tmpdir(), "e2e-"));
const { sessionId } = await createTestSession({
appId: "my-terminal-app",
storageDir,
});
const adapter = createTerminalAdapter({
appId: "my-terminal-app",
storageDir,
});
const sessions = await waitForSessions(adapter);
// sessions[0].id === sessionIdLimits and usage notes.
- Signing does not round-trip. Both
session.signRawandsession.signPayloadgo out over the statement store and expect a real phone to respond. Use this helper for flows that test session discovery, persistence, and logout — not happy-path signing. - Expiry tests still work. The synthesized local account was never registered on the People chain, so any statement-store write from this session fails with
NoAllowanceError. That's the same error the CLI sees when a previously valid session's on-chain attestation has expired. - No
expiresAtoption. The on-disk codec has no expiry field; validity lives on chain. - Corrupted-session cases don't need a helper —
fs.writeFile("<storageDir>/<appId>_SsoSessions.json", "not-hex")from the test is enough.
Signing
After login and attestation, the paired wallet can sign both transactions and raw messages via the statement store. The PolkadotSigner returned by createSessionSigner routes each path to the right host-papp method:
- Transactions (
signTxfrom polkadot-api's perspective — whatsubmitAndWatch,signSubmitAndWatch, contract method calls, etc. invoke) go throughsession.signRawwith thePayloadtag. polkadot-api assembles the full SCALE-encoded signing payload from runtime metadata —callData ‖ extras ‖ additionalSignedsfor every signed extension the chain declares — and hands the bytes to the wallet as an opaque hex blob. The wallet signs the payload as-is, with no envelope wrapping. Any signed extension declared by the runtime (including extensions polkadot-api doesn't know about, e.g.AsPgason Paseo Next v2) survives end-to-end because the wallet doesn't inspect the bytes — it just signs them. - Raw bytes (
signBytes) go throughsession.signRawwith theBytestag. Mobile applies the standard<Bytes>...</Bytes>anti-phishing wrap before signing — appropriate for arbitrary data, the same behaviorsignRawhas across all Polkadot wallets.
Note on previous bugs. Versions prior to
0.1.1routed all signing throughsignRawwith no tag distinction, producingBadProof-rejected signatures. Versions0.1.1through0.2.xthen usedpolkadot-api/pjs-signer, which threwPJS does not support this signed-extension: <name>on any extension outside its eight built-in mappers (notablyAsPgas). The current path bypasses both pitfalls.
Notes
WebSocket transport
The adapter uses @polkadot-api/[email protected], which relies on the global WebSocket exposed by Node ≥21. Older Node versions (18, 20) will fail at connect time with WebSocket is not defined — upgrade Node, or pass an explicit websocketClass from the ws package.
The default WebSocket is constructed without followRedirects: true, so endpoints behind an HTTP redirect will fail to connect. If you must point at an endpoint that does, supply the resolved URL directly via the endpoints option.
How It Works
- QR Pairing -- generates Sr25519 + P256 keypairs, encodes a
polkadotapp://pair?handshake=0x...deep link, subscribes to the statement store - Attestation -- registers the local account on the People chain so it can publish statements
- Signing -- sends encrypted signing requests to the wallet via the statement store, receives signed responses
Sessions are persisted to ~/.polkadot-apps/ and survive across restarts. The SDK loads them asynchronously on startup — subscribe to adapter.sessions.sessions and wait for the first emission.
Dependencies
@novasamatech/host-papp-- Polkadot host-product SDK (auth, attestation, signing)@novasamatech/statement-store-- statement store client and session management@novasamatech/storage-adapter-- storage interface@polkadot-api/ws-provider-- WebSocket JSON-RPC providerneverthrow-- Result type for error handlingqrcode-- QR code generation
Future Work
KvStore↔StorageAdapterbridge. This package implements its own file-backedStorageAdapterfor Node.js (createNodeStorageAdapter). Once@parity/product-sdk-local-storagegrows a file backend with the sameread/write/clear/subscribeResultAsyncshape, replacenode-storage.tswith a thin adapter over it.- Codec re-exports from
@parity/product-sdk-statement-store.testing.tsimports session-account codec helpers (AccountIdCodec,LocalSessionAccountCodec, etc.) directly from@novasamatech/statement-store. Re-exporting them through the in-monorepo wrapper would let this package depend only on workspace siblings. @noble/*major version drift. This package pins@noble/{ciphers,curves,hashes}: ^2.xbecause upstream@polkadot-apps/terminaldid, and thetesting.tscodec helpers use the v2 import paths (@noble/hashes/blake2.js,@noble/curves/nist.js). The rest of the monorepo is on^1.x. Both majors coexist in the lockfile; not a runtime problem today but worth a coordinated bump. Either move the whole monorepo to v2, or rewritetesting.tsagainst v1 paths (@noble/hashes/blake2b.jsetc.).
