npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@unspendablelabs/kontor-portal-client

v0.2.10

Published

TypeScript client for Kontor Portal — BLS registration, challenge login, and chunked file upload

Downloads

194

Readme

Kontor Portal Client

CI codecov npm version License: MIT TypeScript

Overview

KontorPortalClient is a framework-agnostic TypeScript client for Kontor Portal. It handles BLS-backed registration (proof of possession), challenge-response login, and authenticated file upload with Reed–Solomon encoding parameters, Merkle roots, and chunked PUT uploads.

The client uses dependency injection for the wallet signer (BLSSigner) and for file preparation (KontorCryptoProvider), so you can swap Horizon Wallet for another implementation or test doubles.

Installation

npm install @unspendablelabs/kontor-portal-client @kontor/kontor-crypto

Peer dependencies (install if not already present):

npm install bip32 @bitcoinerlab/secp256k1 bitcoinjs-lib

For React bindings, also install React 18+.

@kontor/kontor-crypto is optional — you can skip it if you provide your own KontorCryptoProvider (see Custom adapters).

@noble/curves and @noble/hashes are optional peer dependencies, only required when using InBrowserCustomSigner or the /bls subpath export:

npm install @noble/curves @noble/hashes

Setup

import { KontorPortalClient } from "@unspendablelabs/kontor-portal-client";

const client = new KontorPortalClient({
  portalHost: "https://portal.example.com",
});

All other config fields are optional with sensible defaults (signet network, Horizon Wallet signer, WASM crypto, filestorage_0_0 contract). Override any of them as needed:

import { KontorPortalClient } from "@unspendablelabs/kontor-portal-client";
import { networks } from "bitcoinjs-lib";

const client = new KontorPortalClient({
  portalHost: "https://portal.example.com",
  network: networks.bitcoin, // default: signet (testnet)
  walletNetwork: "mainnet", // default: derived from `network` (mainnet | signet)
  kontorContractAddress: "my_contract", // default: "filestorage_0_0"
  kontorNftContractAddress: "my_nft_contract", // default: "nft_0_0"
  // signer: myCustomSigner, // default: new HorizonWalletSigner()
  // crypto: myCustomCrypto, // default: WASM via @kontor/kontor-crypto
  // wasmUrl: "/custom/path/index.js", // default: "/kontor-crypto/index.js"
  // nonceProvider: myNonceProvider, // default: new InMemoryNonceProvider()
});

walletNetwork

walletNetwork is the wallet-side network identifier ("mainnet", "testnet4", or "signet") that the client expects from the connected wallet. When omitted, it is derived from network:

| network | Derived walletNetwork | | ---------------------- | ----------------------- | | networks.bitcoin | "mainnet" | | anything else (default networks.testnet) | "signet" |

Set walletNetwork: "testnet4" explicitly when targeting testnet4 — bitcoinjs-lib's networks.testnet covers testnet3/testnet4/signet (same bech32 parameters), so the default mapping cannot disambiguate.

Quick start

The unified login() method auto-detects whether the wallet is registered with the Portal and runs registration on the fly when needed. Each step supports optional progress callbacks.

// One call: register if needed + login. Returns the JWT and (when the user
// was just registered) the registration data. The JWT is also stored on the
// client for subsequent authenticated requests.
const result = await client.login({
  onStep: (step) => console.log("Login step:", step),
});

if (result.registration) {
  console.log("Registered new user:", result.registration.userId);
}

// Upload a file. `result.xOnlyPubkey` is always populated (sourced from
// the registration on the new-user path, or from the registry lookup on
// the already-registered path), so you never have to call
// getSignerInfo() yourself just to get this value.
const uploadResult = await client.uploadFile(file, {
  xOnlyPubkey: result.xOnlyPubkey,
  tags: ["document", "contract"],
  onStep: (step) => console.log("Upload step:", step),
  onPrepareProgress: (progress, phase) => {
    console.log(`${phase}: ${Math.round(progress * 100)}%`);
  },
  onUploadProgress: (uploaded, total) => {
    console.log(`Upload: ${Math.round((uploaded / total) * 100)}%`);
  },
});

uploadFile requires a prior successful login() (or a JWT set with setJwt) because uploads use authenticated portal APIs.

Uploading with an NFT mint

uploadFile accepts an optional nft payload that mints a Kontor NFT in the same Bitcoin operation as the agreement creation. When nft is set, the BLS signature is built over the NFT contract's mint(...) instead of the filestorage contract's create-agreement(...). The NFT contract address is configured at client init via KontorPortalClientConfig.kontorNftContractAddress (default "nft_0_0") and is not overridable per call.

const uploadResult = await client.uploadFile(file, {
  xOnlyPubkey: result.xOnlyPubkey,
  nft: {
    nftId: "mona-lisa-001",
    attributes: [{ key: "artist", value: "Leonardo" }],
  },
});

The resulting on-chain agreement_id equals the file_id (the NFT contract's mint invokes filestorage::create_agreement internally), and the Portal creates an nft_mints row in the same database transaction whose status mirrors the agreement (readypendingconfirmed).

Network validation

login() calls signer.getAddress() and validates the wallet's reported network against walletNetwork. On mismatch it throws NetworkMismatchError before any Portal request:

import { NetworkMismatchError } from "@unspendablelabs/kontor-portal-client";

try {
  await client.login();
} catch (err) {
  if (err instanceof NetworkMismatchError) {
    console.error(
      `Wallet on ${err.walletNetwork}, client expects ${err.clientNetwork}.`,
    );
  }
}

You can also probe the wallet network without triggering a login flow:

const { walletNetwork, clientNetwork, matches } =
  await client.detectWalletNetwork();

If you already know the address and want to skip the wallet round-trip (and the network check), pass address directly:

await client.login({ address: "bc1p..." });

API reference

login(options?)

  • Signature: login(options?: UnifiedLoginOptions): Promise<UnifiedLoginResult>
  • Description: Unified flow that detects whether the wallet is registered with the Portal and either logs in directly or runs registration first. Stores the resulting JWT on the client.
  • Parameters:
    • options?.address — Taproot address. When omitted, the signer's getAddress() is called and the returned network is validated against the client's configured walletNetwork.
    • options?.onStep — Called with UnifiedLoginStep names (see Progress callbacks).
  • Returns: UnifiedLoginResult extending LoginResult with:
    • address — Taproot address used for the login (either options.address or the wallet-resolved address).
    • xOnlyPubkey — x-only pubkey of the signer that just logged in. Always populated (sourced from the registration on the new-user path, or from the registry lookup on the already-registered path). Use this directly as UploadOptions.xOnlyPubkey — no follow-up getSignerInfo() round-trip required.
    • blsPubkey — BLS public key of the signer. Same lifecycle as xOnlyPubkey. null only when the registry entry has no bound BLS key (should not happen for users that completed client.login()).
    • registrationnull when the user was already registered, or a RegistrationResult (userId, xOnlyPubkey, blsPubkey, xpubkey) when a registration was performed during this call.
  • Throws: NetworkMismatchError when the wallet network does not match the client's configured walletNetwork (only when options.address is omitted).

detectWalletNetwork()

  • Signature: detectWalletNetwork(): Promise<{ walletNetwork: WalletNetwork; clientNetwork: WalletNetwork; matches: boolean }>
  • Description: Calls signer.getAddress() and reports whether the wallet's network matches the client's configured walletNetwork. Useful for surfacing a clear error to the user before initiating a login flow.

getSignerInfo(idOrPubkeyOrAddress)

  • Signature: getSignerInfo(idOrPubkeyOrAddress: string): Promise<SignerInfo>
  • Description: Looks up the signer in the portal registry and returns signer id and next nonce. If a nonceProvider is configured, nextNonce may be adjusted by getNextNonce.
  • Parameters:
    • idOrPubkeyOrAddress — Any identifier accepted by GET /api/signers/{identifier}:
      • a numeric Kontor signer_id (e.g. "0"),
      • an x-only public key in hex (64 hex chars), or
      • a Bitcoin address registered with the Portal (via login() or node registration).
  • Returns: SignerInfo{ signerId: number; nextNonce: number; chainNonce: number; userId?: string; xOnlyPubkey: string; blsPubkey: string | null }.
    • nextNonce — effective nonce after NonceProvider.getNextNonce arbitration.
    • chainNonce — raw value returned by the Portal registry (Portal-authoritative, used by login() to resync the local nonce tracker).
    • userId — present when the registry entry resolves to a users row (lookup by Bitcoin address or x-only pubkey of a registered user).
    • xOnlyPubkey — 64-char hex x-only public key of the signer, as recorded by Kontor. Always present.
    • blsPubkey — BLS12-381 G2 public key (192 hex chars = 96 bytes), or null when the registry entry has no bound BLS key (e.g. some node entries before register_bls_key).
  • Sends Authorization when a JWT is present. Throws PortalNotFoundError on 404 (e.g. address not registered). Throws a generic error when the registry response is malformed (missing x_only_pubkey).

uploadFile(file, options)

  • Signature: uploadFile(file: File, options: UploadOptions): Promise<UploadResult>
  • Description: Prepares the file (RS params, Merkle metadata), fetches signer info, signs the create-agreement message, initiates the upload session, uploads in chunks, optionally validates the session, and optionally reports nonce usage.
  • Parameters:
    • file — Browser File to upload.
    • options.xOnlyPubkey — Signer x-only pubkey for registry lookup.
    • options.address — Taproot address used for BLS key derivation (recommended when using Horizon Wallet).
    • options.tags — Optional string tags for the file.
    • options.onStep, options.onPrepareProgress, options.onUploadProgress — See Progress callbacks.
  • Returns: UploadResult with sessionId, fileId, merkleRoot, filename, and size.

Uploading with an NFT mint

Pass an nft payload to uploadFile to mint a Kontor NFT in the same Bitcoin operation that creates the file's agreement. The BLS signature is built over the NFT contract's mint(...) entrypoint (instead of create-agreement(...)), so a single signed op covers both the NFT record and its backing agreement.

The NFT contract address is configured at client init via KontorPortalClientConfig.kontorNftContractAddress (default "nft_0_0"); it is not overridable per call.

const result = await client.uploadFile(file, {
  xOnlyPubkey,
  nft: {
    nftId: "mona-lisa-001",
    attributes: [{ key: "artist", value: "Leonardo" }],
  },
});

The resulting on-chain agreement_id equals the file_id. Portal-side, an nft_mints row is created in the same transaction as the agreement and mirrors its status (readypendingconfirmed, or failed if the mint op did not land on Kontor).

getAgreement(agreementId)

  • Signature: getAgreement(agreementId: string): Promise<Agreement>
  • Description: GETs a single agreement by ID. Public endpoint — no JWT required (you do not need to call setJwt() first; any stored JWT is ignored for this call).
  • Parameters:
    • agreementId — The agreement ID to fetch.
  • Returns: Agreement (see fields below, including on-chain txid, block_height, block_time, and the optional thumbnail_url for previews). Throws if not found (404) or on other errors.

listAgreements(options?)

  • Signature: listAgreements(options?: ListAgreementsOptions): Promise<AgreementsResponse>
  • Description: GETs paginated agreements with optional filtering and sorting. Public endpoint — no JWT required.
  • Parameters:
    • options?.limit — Page size (default 20).
    • options?.offset — Offset (default 0).
    • options?.status — Filter by status. Pass a single string (e.g. "ready") or an array (e.g. ["ready", "pending"]) which is serialized as a pipe-separated value (status=ready|pending).
    • options?.users — Array of user IDs, serialized as a comma-separated list.
    • options?.nodes — Array of node IDs, serialized as a comma-separated list.
    • options?.mimeType — MIME type filter (sent as mime_type).
    • options?.sort — Sort field: "created_at" (default on the server), "size", or "filename".
    • options?.sortDir — Sort direction: "asc" or "desc" (sent as sort_dir).
  • Returns: AgreementsResponse with offset, limit, total, and agreements[].

The Agreement type includes the on-chain fields txid (Bitcoin transaction id, hex), block_height, and block_time (Unix timestamp in seconds). All three are nullable until the agreement is confirmed on-chain. The internal transaction_id (UUID) remains available for backward compatibility.

It also exposes an optional thumbnail_url pointing at a 400×400 preview asset — either a short-lived GCS V4 signed URL (*.thumb.jpg for raster images, *.thumb.svg placeholder for other MIME types) or the public SVG fallback at GET /api/thumbnails/fallback?filename=<filename> for legacy rows. The field is undefined on older Portal deployments without thumbnail support, so guard against that before rendering. Use it directly in <img> tags for previews; it does not replace getDownloadUrl() / downloadFile() for the original file bytes.

getNft(nftId)

  • Signature: getNft(nftId: string): Promise<Nft>
  • Description: GETs a single NFT mint by its nft_id. Joins the nft_mints row with its backing agreement and file to return the full NFT shape in one response. Public endpoint — no JWT required.
  • Parameters:
    • nftId — The NFT identifier (≤ 64 UTF-8 bytes).
  • Returns: Nft (see type below). Throws PortalNotFoundError on 404, or a generic error on other failures.

listNfts(options?)

  • Signature: listNfts(options?: ListNftsOptions): Promise<NftsResponse>
  • Description: GETs a paginated list of NFT mints with optional filtering and sorting. Joins each mint with its backing agreement and file, so file_url and thumbnail_url are available in one request. Public endpoint — no JWT required.
  • Parameters:
    • options?.limit — Page size (default 20, max 100).
    • options?.offset — Offset (default 0).
    • options?.status — Filter by mint status. Pass a single string (e.g. "confirmed") or an array (e.g. ["ready", "confirmed"]) serialized as pipe-separated (status=ready|confirmed). Allowed values: ready, pending, confirmed, failed.
    • options?.users — Array of user IDs (file owners), serialized as a comma-separated list.
    • options?.mimeType — MIME type filter (sent as mime_type).
    • options?.sort — Sort field: "created_at" (server default) or "nft_id". Sorting by size or filename is not supported for NFTs.
    • options?.sortDir — Sort direction: "asc" or "desc" (sent as sort_dir).
  • Returns: NftsResponse with offset, limit, total, and nfts[].

The Nft type includes all NFT-specific fields (nft_id, immutable attributes[], status, agreement_status, file_url, thumbnail_url) alongside the backing file fields (filename, mime_type, original_size, agreement_id, file_id, user_id). agreement_id always equals file_id for NFTs — the NFT contract's mint(...) invokes filestorage::create-agreement(...) internally.

file_url is a short-lived GCS V4 signed URL suitable for inline display (<img src=...>). It is null only when the GCS signing key is misconfigured server-side — fall back to getDownloadUrl() in that case. thumbnail_url is always non-null (same policy as Agreement.thumbnail_url).

getDownloadUrl(agreementId, options?)

  • Signature: getDownloadUrl(agreementId: string, options?: DownloadFileOptions): Promise<DownloadUrlResult>
  • Description: Resolves the download URL for an agreement file via GET /api/agreements/{agreement_id}/download?no_redirect=true. The URL is a signed Google Cloud Storage URL when the agreement is ready, or a storage node URL when confirmed. Public endpoint — no JWT required.
  • Parameters:
    • agreementId — The agreement ID to download.
    • options?.forceDownload — When true, sends force_download=true so the resulting URL serves with Content-Disposition: attachment.
  • Returns: { downloadUrl: string }. Throws PortalNotFoundError on 404, or a generic error with the server message for other failures (e.g. 403 when the agreement is not yet downloadable, 503 when no storage node is available).

downloadFile(agreementId, options?)

  • Signature: downloadFile(agreementId: string, options?: DownloadFileOptions): Promise<Blob>
  • Description: Convenience wrapper that calls getDownloadUrl() and then fetches the resulting URL, returning the file content as a Blob. Use it when you want the file bytes in memory; otherwise prefer getDownloadUrl() and pass the URL to <a href>, window.open, or a streaming consumer.
  • Parameters: Same as getDownloadUrl.
  • Returns: Blob with the downloaded file contents.
const blob = await client.downloadFile("file_a1b2...");
const url = URL.createObjectURL(blob);

healthCheck()

  • Signature: healthCheck(): Promise<boolean>
  • Description: GETs {portalHost}/health and returns whether the response is OK. Does not require authentication.
  • Returns: true if the request succeeds with an OK status; false on network or non-OK responses.

setJwt(jwt) / clearJwt() / getJwt() / isAuthenticated()

  • setJwt(jwt: string): void — Stores the JWT for subsequent authenticated calls.
  • clearJwt(): void — Removes the stored JWT (used for logout flows).
  • getJwt(): string | null — Returns the stored JWT or null.
  • isAuthenticated(): booleantrue only if a JWT is set, the payload decodes, an exp claim exists, and exp is in the future.

HorizonWalletSigner

  • Signature: new HorizonWalletSigner(timeoutMs?: number) (default 30000)
  • Description: BLSSigner implementation that talks to window.HorizonWalletProvider (browser extension). Throws if the provider is missing or RPC calls fail or time out.

Progress callbacks

Step callbacks fire in order:

| Flow | Steps | |------|--------| | Login (already registered) | "checking_wallet""checking_registration""challenge""signing""authenticating" | | Login (new user) | "checking_wallet""checking_registration""pop""signing""registering""challenge""signing""authenticating" | | Upload | "preparing""signing""initiating""uploading""validating" |

Note: the login flow skips "checking_wallet" when options.address is supplied (the signer's getAddress() is not called and the network check is bypassed).

onPrepareProgress(progress, phase) — Passed through to KontorCryptoProvider.prepareFile. progress is in the inclusive range 0–1. phase is a ProgressPhase from kontor-crypto: "reading", "encoding", "merkle", or "finalizing".

onUploadProgress(bytesUploaded, totalBytes) — Reports uploaded byte count after each chunk advance; the final call uses (totalBytes, totalBytes).

Custom adapters

BLSSigner (no Horizon Wallet)

import type {
  BLSSigner,
  BLSPoP,
  BLSSignParams,
  WalletAddress,
} from "@unspendablelabs/kontor-portal-client";

class MyCustomSigner implements BLSSigner {
  async getAddress(): Promise<WalletAddress> {
    /* return { address: "bc1p...", network: "mainnet" | "testnet4" | "signet" } */
  }
  async getBLSPoP(address: string): Promise<BLSPoP> {
    /* return xpubkey, blsPubkey, schnorrSig, blsSig */
  }
  async signBLS(params: BLSSignParams): Promise<string> {
    /* sign with either params.message or params.messageHex; params.dst is required; params.address identifies the account */
  }
}

BLSSignParams: supply either message (UTF-8) or messageHex, not both, plus dst (domain separation tag). address (optional) identifies the account whose BLS key should sign.

Breaking change in v0.2.0: BLSSigner now requires a getAddress() method. The unified login() calls it to obtain the active Taproot address and to validate the wallet's network against the client's configured walletNetwork.

InBrowserCustomSigner

A reference, no-extension BLSSigner that derives a Taproot key + a BLS12-381 G1 (min-sig) key entirely in the browser. It supports two real-world key sources via a discriminated-union config:

  1. BIP-39 seed — Horizon-Wallet parity. Uses the same EIP-2333 derivation path (m/12381/{coinType}/{accountIndex}/0) and Taproot path (default m/86'/{coinType}'/{accountIndex}'/0/0) as the Horizon Wallet extension.
  2. Single 32-byte secp256k1 private key — for Web3Auth and other social wallets that hand back a raw key (e.g. provider.request({ method: "private_key" })). The BIP-32 chain code is synthesized deterministically from the private key (HMAC-SHA512("KONTOR-WEB3AUTH-CHAINCODE-V1", privateKey).slice(32)) so we still produce a valid xpub that Portal already understands.
import { InBrowserCustomSigner, KontorPortalClient } from "@unspendablelabs/kontor-portal-client";
import { mnemonicToSeedSync } from "bip39";
import { networks } from "bitcoinjs-lib";

// 1. From a BIP-39 seed (Horizon-Wallet parity)
const seedSigner = new InBrowserCustomSigner({
  seed: mnemonicToSeedSync("..."),
  network: networks.bitcoin,
  // accountIndex: 0,                                    // optional, default 0
  // taprootDerivationPath: "m/86'/0'/0'/0/0",           // optional, derived from network coinType + accountIndex
  // walletNetwork: "mainnet",                           // optional, derived from network
});

// 2. From a single secp256k1 private key (Web3Auth, social wallets)
const pkSigner = new InBrowserCustomSigner({
  privateKey: web3authPrivateKey, // 32 bytes
  network: networks.bitcoin,
});

const client = new KontorPortalClient({
  portalHost: "https://portal.example.com",
  signer: seedSigner, // or pkSigner
});

InBrowserCustomSigner is a single-account signer: getBLSPoP(address) and signBLS({ address }) validate the supplied address against the cached Taproot address and throw on mismatch.

/bls subpath (raw BLS primitives)

For downstream wallets that want the underlying BLS12-381 helpers without the rest of the client (e.g. Horizon-Wallet bundling them directly into the extension), this package re-exports them under a stable /bls subpath whose API matches Horizon-Wallet's tool/bls-entry.js byte-for-byte:

import {
  KONTOR_BLS_DST,
  sign,
  getPublicKey,
  deriveMasterSK,
  deriveBlsKey,
  signBlsBinding,
  schnorrBindingHash,
} from "@unspendablelabs/kontor-portal-client/bls";

All functions are pure (no window access, no async) and faithful TypeScript ports of the JS reference: same EIP-2333 key derivation, same KONTOR_BLS_DST, same Schnorr/BLS binding prefixes, same BLS12-381 G1 min-sig signatures.

KontorCryptoProvider

import type { KontorCryptoProvider } from "@unspendablelabs/kontor-portal-client";

const crypto: KontorCryptoProvider = {
  async prepareFile(file, onProgress) {
    /* File | Uint8Array | ArrayBuffer — return PrepareResult; optional onProgress */
  },
};

NonceProvider

import type { NonceProvider } from "@unspendablelabs/kontor-portal-client";

const nonceProvider: NonceProvider = {
  async getNextNonce(signerId, chainNonce) {
    /* return the nonce to use (e.g. coordinated with chainNonce from registry) */
  },
  async reportNonceUsed(signerId, nonceUsed) {
    /* optional — called after a successful upload using that nonce */
  },
  async setNonce(signerId, nonce) {
    /* optional — called by login() to force-overwrite the local tracker
       with the Portal's authoritative next_nonce (resync across tabs/sessions) */
  },
};

React bindings

The library ships a React context provider and hook that manage a single KontorPortalClient instance per component tree, along with authentication state (JWT persistence via localStorage).

PortalClientProvider

Wrap your app (or a subtree) with the provider. It accepts the same KontorPortalClientConfig used by the client constructor.

Note: config is read once on mount. Changing config after the initial render has no effect. Keep the reference stable (module-level constant or useMemo).

import { PortalClientProvider } from "@unspendablelabs/kontor-portal-client/react";

<PortalClientProvider
  config={{
    portalHost: "https://portal.example.com",
    nonceProvider, // optional — defaults to InMemoryNonceProvider
  }}
>
  {children}
</PortalClientProvider>

usePortalClient()

Returns the shared client and auth helpers:

import { usePortalClient } from "@unspendablelabs/kontor-portal-client/react";

function MyComponent() {
  const {
    client,          // KontorPortalClient instance
    status,          // PortalAuthStatus
    jwt,             // current JWT or null
    isRegistered,    // true when a userId is stored
    portalUserId,
    taprootAddress,
    xOnlyPubkey,
    login,           // () => Promise<void> — register-if-needed + login
    logout,          // () => void
    reset,           // () => void — clear all stored state
  } = usePortalClient();
}

PortalAuthStatus is one of "loading" | "authenticated" | "needs_login" | "logging_in" | "error". login() handles registration on demand, so there is no separate "needs_registration" state.

On failure, login() updates status to "error" (with error set to the message) and re-throws the underlying error, so callers awaiting the call can try/catch it directly without also subscribing to status / error:

try {
  await login();
} catch (err) {
  // handle the failure (e.g. toast, retry button, …)
  // `status` is already `"error"` and `error` already holds `err.message`
}

WASM crypto setup

@kontor/kontor-crypto is a WASM module that handles Reed–Solomon encoding, Merkle tree computation, and file preparation. Because WASM cannot be bundled like regular JavaScript, the module must be served as a static asset by your application.

At runtime the client loads the module with a dynamic import() from a URL (default: /kontor-crypto/index.js). You need to copy the package files into your public/static directory so the browser can fetch them.

Copy the files

After installing @kontor/kontor-crypto, copy its contents to a kontor-crypto/ folder inside your public directory:

cp -r node_modules/@kontor/kontor-crypto/dist/* public/kontor-crypto/

Tip: Add this to a postinstall script so it stays in sync:

// package.json
"scripts": {
  "postinstall": "cp -r node_modules/@kontor/kontor-crypto/dist/* public/kontor-crypto/"
}

Framework-specific examples

Vite — files in public/ are served at the root:

cp -r node_modules/@kontor/kontor-crypto/dist/* public/kontor-crypto/

No config change needed — the default /kontor-crypto/index.js URL will work.

Next.js — same approach with the public/ directory:

cp -r node_modules/@kontor/kontor-crypto/dist/* public/kontor-crypto/

Custom URL

If you serve the files from a different path, pass wasmUrl:

const client = new KontorPortalClient({
  portalHost: "https://portal.example.com",
  wasmUrl: "/assets/crypto/index.js",
});

Or create a standalone crypto provider with createCryptoProvider:

import { createCryptoProvider } from "@unspendablelabs/kontor-portal-client";

const crypto = createCryptoProvider("/assets/crypto/index.js");
const client = new KontorPortalClient({
  portalHost: "https://portal.example.com",
  crypto,
});

When crypto is provided, wasmUrl is ignored.

Development

npm install
npm run lint        # ESLint
npm run typecheck   # TypeScript (no emit)
npm test            # Vitest
npm run test:coverage

A pre-push git hook runs lint, typecheck, and tests automatically.

License

MIT