Skip to content

interop-alliance/was-react

Repository files navigation

@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.

About

Wallet Attached Storage library for React apps (identity, session, sync, etc).

Resources

License

Contributing

Stars

0 stars

Watchers

0 watching

Forks

Packages

 
 
 

Contributors