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

@interop/was-react

v0.1.5

Published

React library for building 'Bring Your Own Everything' (BYOE) apps on Wallet Attached Storage: DID Auth login via CHAPI wallet, local-first encrypted storage, and background sync to a WAS server.

Readme

@interop/was-react (@interop/was-react)

NPM Version

React library for building "Bring Your Own Everything" (BYOE) apps on Wallet Attached Storage: DID-Auth login via a CHAPI wallet, local-first encrypted storage, and background sync to a WAS server.

Table of Contents

Background

"Bring Your Own Everything" (BYOE) is a way to build web apps with no backend that the app owns. The user brings their own identity (a wallet) and their own storage (Wallet Attached Storage, WAS), and the app stores everything encrypted in that user-owned space. The app is a Relying Party (RP): it authenticates via "Login With Wallet" (CHAPI) and reads and writes the user's WAS space using wallet-delegated authorization capabilities (zcaps). It never owns the space, never holds the wallet's root key, and invokes only the zcaps the wallet grants it.

"Bring Your Own Storage" (BYOS) is the storage half of that model. Every application collection is encrypted client-side as an Encrypted Data Vault (EDV): the WAS server only ever sees opaque JWE envelopes and can neither read nor search the plaintext. Data is local-first -- a local RxDB (IndexedDB) database holds the encrypted envelopes and replicates them to WAS in the background. The app works fully offline; sync resumes on reconnect.

@interop/was-react is the reusable plumbing for that model, extracted from a production BYOE app. It wraps @interop/was-client and owns identity derivation, the CHAPI login flow, the session lifecycle store, the encrypted local replica, WAS replication, and a small set of React hooks and optional MUI components. An app supplies its configuration, its collection registry, and its own domain and UI; the library owns everything in between.

Install

Node.js 24+ is recommended. The package is ESM-only.

pnpm add @interop/was-react

Peer dependencies (install the ones you use):

pnpm add react zustand rxdb

react >= 19, zustand ^5, and rxdb ^17 are required peers. The optional @interop/was-react/mui entry additionally needs @mui/material, @mui/icons-material, and react-router; the core entry never imports them.

pnpm add @mui/material @mui/icons-material @emotion/react @emotion/styled react-router

Quick start

An app builds one WasAppConfig, a StoreRegistry (per-collection hydrate and patch handlers), wraps its tree in <WasSessionProvider>, and drives the session through the hooks. The example below wires a single notes collection.

1. Configuration

// app.config.ts
import type { WasAppConfig } from '@interop/was-react'

export const config: WasAppConfig = {
  appName: 'Notes',
  appOrigin: 'https://notes.example',
  wasServerUrl: 'https://was.example',
  collections: [{ key: 'notes', id: 'notes' }],
  credential: {
    credentialType: 'NotesAppKey',
    vocabBase: 'urn:notes-app:vocab#'
  }
}

collections maps each app-side key (the local RxDB collection handle) to a WAS collection id (a deliberately unprefixed, generic name shared across interoperable apps). credential names the self-issued seed credential the first-run flow mints. All other fields (mediatorBase, dbName, storageKeyPrefix, sync, expiry) are optional with documented defaults.

2. Entity stores and the registry

// stores.ts
import { createEntityStore, type StoreRegistry } from '@interop/was-react'

export interface Note {
  id: string
  title: string
  body: string
  createdAt: string
  updatedAt: string
  deviceId: string
}

export const useNotes = createEntityStore<Note>('notes')

export const registry: StoreRegistry = {
  notes: {
    hydrate: () => useNotes.getState().hydrate(),
    upsert: doc => useNotes.getState().patch(doc as Note),
    drop: uuid => useNotes.getState().drop(uuid),
    clear: () => useNotes.getState().replaceAll([])
  }
}

createEntityStore returns a zustand hook holding the decrypted payloads as a Map<uuid, Note>; its insert / update / remove verbs persist through the encrypted local store, while hydrate / patch / drop / replaceAll are the handlers the rehydrate mechanism drives on login, remote sync, and logout.

3. Provider

// main.tsx
import { WasSessionProvider } from '@interop/was-react'
import { config } from './app.config.js'
import { registry } from './stores.js'

export function Root() {
  return (
    <WasSessionProvider config={config} registry={registry}>
      <App />
    </WasSessionProvider>
  )
}

4. Login page

// LoginPage.tsx
import { useLogin } from '@interop/was-react'

export function LoginPage() {
  const { login, status, phase, error } = useLogin()
  const busy = status === 'authenticating'

  return (
    <div>
      <button onClick={() => void login()} disabled={busy}>
        {busy ? 'Connecting your wallet...' : 'Login with wallet'}
      </button>
      {busy && phase && <p>{phase}</p>}
      {error && <p role="alert">{error}</p>}
    </div>
  )
}

5. Reading and writing

import { uuidv7 } from 'uuidv7'
import { getDeviceId } from '@interop/was-react'
import { useNotes } from './stores.js'

export function Notes() {
  const notes = useNotes(state => [...state.byId.values()])
  const insert = useNotes(state => state.insert)

  async function addNote() {
    const now = new Date().toISOString()
    await insert({
      id: uuidv7(),
      title: 'Untitled',
      body: '',
      createdAt: now,
      updatedAt: now,
      deviceId: getDeviceId()
    })
  }

  return (
    <div>
      <button onClick={() => void addNote()}>Add note</button>
      <ul>
        {notes.map(note => (
          <li key={note.id}>{note.title}</li>
        ))}
      </ul>
    </div>
  )
}

Entity payloads MUST carry updatedAt and deviceId (from getDeviceId()), stamped on EVERY insert and update: they are the last-write-wins pair that settles concurrent multi-device edits of the same entity. A payload without them loses every sync conflict.

The MUI entry supplies a router gate and status UI on top of this; see Entry points.

Login flow

Login is driven by CHAPI Verifiable Presentation Requests (VPRs). The useLogin().login() action (backed by loginWithWallet) runs the flow, with a phase string surfaced for a progress line (probing to storing-key to requesting-grants to verifying):

  1. CHAPI polyfill loads lazily. The credential-handler-polyfill is loaded on demand the first time a wallet request is made, not at import time.
  2. Probe (popup #1). A VPR asking for DIDAuthentication plus the app's seed credential is sent to the wallet. No credential returned means this is a first run.
  3. First run: mint and store the seed. A fresh 32-byte master seed is generated (crypto.getRandomValues), a seed credential is self-issued (issueSeedCredential, using the configured credentialType and vocabBase plus the app origin anti-phishing bind), and stored in the wallet via chapiStore. The flow blocks until the wallet confirms the store -- a dismissed store would silently break cross-device recovery.
  4. Returning login: recover and verify. When the probe returns the seed credential, parseSeedCredential recovers the seed and cryptographically verifies the credential is self-issued, origin-bound, and seed-to-DID bound.
  5. Derive identity. The stable did:key controller and its signer are derived deterministically from the seed via CapabilityAgent.fromSeed (initAppSession / deriveIdentity). The same seed yields the same identity on every device.
  6. Request grants (popup #2). buildGrantsVpr requests a per-collection read/write zcap (plus a read-only space grant) for the controller DID. The wallet provisions the collections and returns a signed presentation.
  7. Verify the response. verifyLoginPresentation checks the VP and embedded proofs (purpose authentication, matching domain and challenge), and checkGrants asserts every zcap is controlled by the app DID, targets the configured wasServerUrl, shares one space, and is unexpired.
  8. Activate. The session (seed, grants, earliest expiry) is persisted to IndexedDB, the encrypted local store is opened, the entity stores hydrate, and background WAS sync starts.

Session lifecycle

The session is owned by a zustand auth store built once per app by the provider (createAuthStore). Its status is idle to restoring to unauthenticated / authenticating / authenticated, and it is the router gate: a protected route waits for the restore attempt to settle before choosing between the app and the login page.

  • Restore (zero popups). On mount, restore() reads the persisted session from IndexedDB and, if present and consistent, re-derives the identity, opens the local store, hydrates, and starts sync with no wallet interaction. A missing, corrupt, or wrong-server record falls through to unauthenticated.
  • Login. useLogin().login() runs the full login flow.
  • Reconnect. Grants are expiry-only. The store watches the earliest grant expiry and, once within the warning window (expiry.warningMs, default 1h) or after a live 401/403, sets accessExpired. useReconnect().reconnect() re-requests grants with the existing seed (one wallet popup, same identity, same data) and restarts sync.
  • Logout. useLogout() stops sync, closes and forgets the local store, clears the entity stores, and clears the persisted session.
  • Expiry. Because the seed survives grant expiry and the derived DID and vault keys are stable, an expired session re-grants in place; previously stored data stays readable.

Hooks:

| Hook | Returns | | ----------------- | --------------------------------------------------------------------------------------- | | useSession() | status, phase, error, controllerDid, expires, accessExpired, reconnecting | | useLogin() | { login, status, phase, error } | | useLogout() | () => Promise<void> | | useReconnect() | { accessExpired, reconnecting, reconnect } | | useSyncStatus() | { state, label, title } (see below) | | useAppReady() | { ready, error } -- the hydration gate | | useAuthStore() | the bound vanilla store (for getState().restore(), etc.) |

Sync architecture

  • Local-first replica. An always-on local encrypted RxDB (Dexie/IndexedDB) database (LocalStore) holds one collection per app collection. Every at-rest row is { id, updatedAt, version, data }, where data is an EDV envelope { id, sequence, jwe }. The app reads exclusively from the in-memory entity stores hydrated from this replica, so it works fully offline.
  • Envelope encryption. Each collection is encrypted with its own X25519 key-agreement key, HKDF-derived from the master seed with the label kak:v1:<collectionId> (deriveCollectionKeys). HKDF one-wayness means a shared per-collection key exposes nothing about the master seed or the sibling collections. The WAS server never sees plaintext.
  • Replication. A per-session SyncController runs RxDB replication per collection over a WasSyncPort (signed requests authorized by the granted zcaps). Pull is driven by the WAS changes feed; a low-frequency periodic re-sync (sync.pollMs, default 15s) keeps open sessions converging.
  • Conflict resolution. Last-writer-wins on the payload's own (updatedAt, deviceId) (ISO lexical compare, with a per-install random deviceId tiebreaker). Updates re-encrypt in place under the same envelope id with sequence+1 (a mutable-head model); deletes are soft-delete tombstones.
  • Status. useSyncStatus() rolls the per-collection replication states up to an aggregate: offline (no replication running / local-only), or error > syncing > synced. The SyncStatusChip MUI component renders it.

Entry points

The package exposes three entry points. ./mui and ./dev are never re-exported from the root, so an app that does not use them pays no dependency cost.

| Import | Contents | Extra peers | | ------------------------ | ------------------------------------------------------------------------------------ | ------------------------------------------------------ | | @interop/was-react | Core: config, identity, auth, sync, storage, session store, React provider and hooks | react, zustand, rxdb | | @interop/was-react/mui | Optional ProtectedRoute, ReconnectBanner, SyncStatusChip | @mui/material, @mui/icons-material, react-router | | @interop/was-react/dev | Node-only provisionDevGrants + the was-provision-dev-grants CLI | (none; Node only) |

MUI usage:

import { Routes, Route } from 'react-router'
import {
  ProtectedRoute,
  ReconnectBanner,
  SyncStatusChip
} from '@interop/was-react/mui'
import { LoginPage } from './LoginPage.js'

export function App() {
  return (
    <Routes>
      <Route path="/login" element={<LoginPage />} />
      <Route element={<ProtectedRoute loginPath="/login" />}>
        <Route
          path="/"
          element={
            <>
              <SyncStatusChip />
              <ReconnectBanner />
              <Notes />
            </>
          }
        />
      </Route>
    </Routes>
  )
}

ProtectedRoute calls restore() on mount, shows a spinner while the session restores and the stores hydrate, redirects an unauthenticated visitor to loginPath, and renders the routed <Outlet /> once ready.

Dev tooling

The ./dev entry provisions a Space, collections, and delegated read/write grants against a running was-teaching-server, so an app can dev-sync without a CHAPI wallet in the loop. It is Node-only (uses node:fs).

CLI:

was-provision-dev-grants \
  --server-url http://localhost:3002 \
  --seed <hex-or-base64url 32-byte seed> \
  --collections notes \
  --space-name "Dev Space" \
  --out ./public/dev-grants.local.json

Programmatic:

import { provisionDevGrants } from '@interop/was-react/dev'

const result = await provisionDevGrants({
  serverUrl: 'http://localhost:3002',
  seed: mySeedBytes,
  collections: ['notes'],
  outFile: './public/dev-grants.local.json'
})
// result.grants, result.spaceId, result.spaceUrl, result.appDid

A throwaway "provisioner" identity owns the created Space (a genuine cross-identity delegation, as in the real wallet-to-relying-party flow), and a per-collection RW zcap is delegated to the app DID derived from seed. Pass --probe (or probe: true) to check whether the delegated zcap authorizes PUTting the EDV encryption marker.

Testing

The repo runs three test tiers:

pnpm run test:node       # Vitest unit tests (test/node/), Node
pnpm run test:browser    # Playwright tests (test/browser/), real Chromium
pnpm test                # fix + lint + typecheck + node + browser

pnpm run test:coverage runs the Vitest suite with V8 coverage. The Playwright tier runs against a Vite dev server that serves and transforms the TypeScript source on the fly; there is no standalone browser app.

Contribute

PRs accepted. If editing the Readme, please conform to the standard-readme specification.

License

MIT License © 2026 Interop Alliance.