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.
- Background
- Install
- Quick start
- Login flow
- Session lifecycle
- Sync architecture
- Entry points
- Dev tooling
- Testing
- Contribute
- License
"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.
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
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.
// 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.
// 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.
// 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>
)
}// 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>
)
}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 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):
- CHAPI polyfill loads lazily. The
credential-handler-polyfillis loaded on demand the first time a wallet request is made, not at import time. - 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.
- 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 configuredcredentialTypeandvocabBaseplus the apporiginanti-phishing bind), and stored in the wallet viachapiStore. The flow blocks until the wallet confirms the store -- a dismissed store would silently break cross-device recovery. - Returning login: recover and verify. When the probe returns the seed
credential,
parseSeedCredentialrecovers the seed and cryptographically verifies the credential is self-issued, origin-bound, and seed-to-DID bound. - Derive identity. The stable
did:keycontroller and its signer are derived deterministically from the seed viaCapabilityAgent.fromSeed(initAppSession/deriveIdentity). The same seed yields the same identity on every device. - Request grants (popup #2).
buildGrantsVprrequests 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. - Verify the response.
verifyLoginPresentationchecks the VP and embedded proofs (purposeauthentication, matching domain and challenge), andcheckGrantsasserts every zcap is controlled by the app DID, targets the configuredwasServerUrl, shares one space, and is unexpired. - 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.
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 tounauthenticated. - 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, setsaccessExpired.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.) |
- 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 }, wheredatais 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
SyncControllerruns RxDB replication per collection over aWasSyncPort(signed requests authorized by the granted zcaps). Pull is driven by the WASchangesfeed; 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 randomdeviceIdtiebreaker). Updates re-encrypt in place under the same envelope id withsequence+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), orerror > syncing > synced. TheSyncStatusChipMUI component renders it.
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.
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.appDidA 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.
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.
PRs accepted. If editing the Readme, please conform to the standard-readme specification.
MIT License © 2026 Interop Alliance.