Browser client API
Complete reference for VaultClient configuration, lifecycle, encryption, recovery, devices, pairing, rotation, storage, results, and failure handling.
@ownfold/browser owns the client-side vault lifecycle. It is the only high-level package that
holds an unlocked root-key handle, encrypts or decrypts application records, persists device
material, imports Recovery Kits, and coordinates sibling tabs.
Installation
pnpm add @ownfold/browser@beta @ownfold/fetch@beta
Use any package that implements VaultTransport & VaultRotationTransport. The example below uses
the fetch transport, but the lifecycle API is identical with tRPC, TanStack Start, or a custom
transport.
import { createVaultClient } from "@ownfold/browser"
import { createFetchVaultTransport } from "@ownfold/fetch"
export const vault = createVaultClient({
transport: createFetchVaultTransport({
baseUrl: "/api/ownfold",
}),
autoLockMs: 10 * 60 * 1_000,
deviceLabel: "Chrome on this device",
})
Call initialize() once before rendering lifecycle controls or encrypting records. In React,
<VaultProvider> performs this initialization and subscribes to state changes.
createVaultClient(options)
Returns one VaultClient. A client represents the current browser profile, not a user account.
Dispose it when replacing the instance during tests, hot-module replacement, or application
teardown.
transportVaultTransport & VaultRotationTransport
VaultTransport & VaultRotationTransportstorage?VaultStorage
VaultStoragecryptoEngine?CryptoEngine
CryptoEnginenow?() => Date
() => DatecreateVaultId?() => string
() => stringcreateDeviceId?() => string
() => stringcreatePairingId?() => string
() => stringcreateRotationId?() => string
() => stringdeviceLabel?string
stringpairingTtlMs?number
numberautoLockMs?number
numberlifecycleChannelFactory?VaultLifecycleChannelFactory
VaultLifecycleChannelFactoryclientInstanceId?string
stringOption guidance
| Option | Production guidance |
|---|---|
transport |
Required. It must authenticate using the host application’s session. It must not accept a caller-supplied user ID. |
storage |
Defaults to IndexedDbVaultStorage. Supply a custom implementation only when it offers equivalent protected device-key persistence and interruption recovery. |
cryptoEngine |
Defaults to the lazy browser crypto engine. Use a worker engine for stronger main-thread isolation; use deterministic engines only in tests. |
autoLockMs |
Defaults to 15 minutes. Set it from an explicit product security policy. It must be a positive duration. |
deviceLabel |
Human-readable metadata visible to the account holder and application server. Never include secrets or decrypted content. |
pairingTtlMs |
Defaults to 10 minutes. The server rejects offers longer than 15 minutes. |
ID factories and now |
Test seams. Production should keep the secure crypto.randomUUID() and real clock defaults. |
lifecycleChannelFactory |
Defaults to BroadcastChannel coordination. Replace only for testing or a browser runtime without that API. |
Results and errors
Every expected failure is returned as Result<T, OwnfoldError> from better-result.
const encrypted = await vault.encryptJson({
namespace: "notes",
recordId: noteId,
ownerId: session.user.id,
value: note,
})
if (encrypted.status === "error") {
switch (encrypted.error._tag) {
case "VaultLockedError":
// Ask the user to unlock; no record was written.
break
case "RotationInProgressError":
// Resume or finish rotation before creating a new envelope.
break
default:
// Map the stable error code to application UI and safe telemetry.
break
}
} else {
await saveCiphertext(encrypted.value)
}
Do not log a whole input, Recovery Kit, envelope, decrypted result, or error cause. Error _tag,
code, operation name, and a host request ID are sufficient for most diagnostics.
Initialization and state
initialize()
Reconciles IndexedDB state, pending pairing/replacement/rotation state, and remote vault metadata. It destroys any previously held root-key handle first. The returned state is also emitted to subscribers.
Common outcomes:
| State | Meaning | Next action |
|---|---|---|
not-created |
Neither local nor remote vault metadata exists. | Call createVault(). |
sync-pending |
Local onboarding state exists, but remote metadata creation failed. | Keep the Recovery Kit safe and call resumeSync(). |
recovery-unverified |
A vault and Recovery Kit exist, but the downloaded file has not been proven recoverable. | Import the downloaded file and call verifyRecoveryKit(). |
ready-locked |
This device is enrolled; no root-key handle is active. | Call unlock(). |
ready-unlocked |
Record operations are available until lock or inactivity timeout. | Encrypt/decrypt, then lock when finished. |
unavailable-on-device |
The account has a vault but this profile has no authorized local device identity. | Restore or begin pairing. |
pairing-pending |
This browser is waiting for approval from an unlocked device. | Poll with refreshPairing() or cancel. |
operation-error |
An operation failed while a stable prior state remains recoverable. | Render the typed error; call dismissError() to return to previous. |
State accessors
const current = vault.getState()
const unsubscribe = vault.subscribe((state) => render(state))
const rotation = vault.getRotationProgress()
const stopRotationUpdates = vault.subscribeRotationProgress((progress) => {
renderRotationProgress(progress)
})
// Later
unsubscribe()
stopRotationUpdates()
vault.dispose()
subscribe() and subscribeRotationProgress() synchronously invoke the listener with the current
value. dispose() clears listeners, timers, lifecycle channels, activity handlers, and active key
access. It does not delete IndexedDB or revoke the device.
Create and verify a vault
The strict onboarding sequence is intentionally non-skippable at the client API level.
Create an in-memory draft
createVault() generates a random root key locally and moves from not-created to
draft-unprotected. No root key is sent to the server.
Create and download the Recovery Kit
createRecoveryKit(secret) encrypts the root key, saves pending local state, and creates remote
metadata. Download the returned object with RecoveryKitFile.download().
Re-import the same file
Read the user’s selected file. Pass its text and the secret to verifyRecoveryKit().
Enroll this device
Successful verification compares the recovered root key with the in-memory key, creates a device key, uploads only its public key and encrypted device envelope, and marks recovery verified.
import { RecoveryKitFile } from "@ownfold/browser"
const draft = await vault.createVault()
if (draft.status === "error") return showError(draft.error)
const created = await vault.createRecoveryKit(recoverySecret)
if (created.status === "error") return showError(created.error)
RecoveryKitFile.download(created.value)
const imported = await RecoveryKitFile.read(fileInput.files[0])
if (imported.status === "error") return showError(imported.error)
const verified = await vault.verifyRecoveryKit(
JSON.stringify(imported.value),
recoverySecret,
)
If remote creation fails after local persistence, the client enters sync-pending. Do not restart
onboarding or generate a second kit. Call resumeSync(); creation is guarded by vault identity and
conflict handling.
Record encryption
namespacestring
stringrecordIdstring
stringownerId?string
stringvalueJsonValue
JsonValueencryptJson(input) and decryptJson(input)
JSON input must be acyclic plain JSON: strings, booleans, finite numbers, null, arrays, and plain
objects. undefined, BigInt, functions, symbols, class instances, cyclic values, non-finite
numbers, and excessive nesting return InvalidInputError.
const encrypted = await vault.encryptJson({
namespace: "notes",
recordId: note.id,
ownerId: session.user.id,
value: {
title: note.title,
body: note.body,
tags: note.tags,
},
})
if (encrypted.status === "error") return showError(encrypted.error)
await notesApi.put({
id: note.id,
encryptedPayload: encrypted.value,
})
Decrypt using the exact same context:
const decrypted = await vault.decryptJson({
namespace: "notes",
recordId: row.id,
ownerId: session.user.id,
payload: row.encryptedPayload,
})
if (decrypted.status === "error") return showError(decrypted.error)
renderNote(decrypted.value)
namespace, recordId, ownerId, vaultId, key version, encoding, envelope format, and
algorithm identifiers are authenticated. Changing a database row ID, moving ciphertext between
namespaces or users, or editing envelope metadata causes decryption to fail.
Binary operations
encryptBinary() accepts a Uint8Array; decryptBinary() returns a new Uint8Array. The entire
value is currently held in memory. Do not use it for unbounded files or streams. Enforce host upload
limits before allocating the buffer.
Locking and unlocking
lock()destroys active root-key access, clears the inactivity timer, moves toready-locked, and broadcasts the lock to sibling tabs.unlock()normally opens the local encrypted device envelope. Passing a secret instead opens the locally retained Recovery Kit when one exists.recordActivity()resets the inactivity deadline only while unlocked. The default browser activity listeners call it for trusted user interaction.getAutoLockMs()exposes the configured duration for UI copy.
Locking is not deletion. Encrypted device material remains in IndexedDB. Reloading always starts
from a locked state after initialize().
Restore and local-state deletion
restore(contents, secret) is valid only from unavailable-on-device. It verifies the active
vaultId and recoveryKitId, opens the kit locally, creates a new device identity, registers its
encrypted device envelope, persists local state, and returns ready-unlocked.
destroyLocalState() destroys active key access and clears this browser’s IndexedDB records,
including pending pairing and rotation data. It does not delete remote vault metadata,
ciphertext, another device, or the account. Afterward the state is normally
unavailable-on-device.
Recovery Kit replacement
Replacement is a two-step, interruption-safe operation:
prepareRecoveryKitReplacement(secret)creates and persists a replacement while the old kit remains authoritative.- Download the returned kit and have the user import it.
completeRecoveryKitReplacement(contents, secret)proves the exact pending kit decrypts to the current root key, updates remote metadata with the expected revision, saves it locally, and clears pending state.
Use getPendingRecoveryKitReplacement() after reload to resume the verification UI. Replacement
is rejected while root rotation is pending.
Devices and pairing
| Method | Valid state | Behavior |
|---|---|---|
listDevices() |
Any authenticated client state | Returns server-visible device summaries. Labels and activity timestamps are metadata. |
getCurrentDeviceId() |
Any state | Reads the local enrolled ID, or null. |
revokeDevice(id, revision) |
Locked or unlocked ready state | Uses optimistic concurrency. Revoking the current device destroys local state immediately. |
beginPairing() |
unavailable-on-device |
Creates a short-lived X25519 device key and offer, then persists the private key locally. |
refreshPairing() |
pairing-pending |
Checks for approval and opens the returned device envelope locally. |
cancelPairing() |
pairing-pending |
Cancels pending remote state when necessary and deletes the pending local key. |
approvePairing(serializedOffer) |
ready-unlocked |
Validates the exact offer and expiry, then wraps the root key to the new device public key. |
Treat a serialized offer like a capability: display it only to the authenticated user, expire it quickly, and confirm the target device label. The offer contains no root key, but approval grants future vault access.
Root-key rotation
Rotation requires an application-owned RotationRecordStore; Ownfold cannot discover the host’s
encrypted record table.
prepareRootKeyRotation(secret)creates the next root key, replacement Recovery Kit, current device envelope, pending local checkpoint, and remote rotation record.- Download and re-import that exact kit.
completeRootKeyRotation({ recoveryKit, secret, records, batchSize })rewraps record data keys, creates replacement envelopes for every active device, and performs the atomic cutover.- Subscribe to progress to show
rewrapping-records,updating-devices, andcommittingphases.
The default batch size is 100; valid values are 1–1000. Interrupted runs resume from the remote
checkpoint. Content ciphertext is preserved; only wrapped data keys and root-key envelopes change.
See Key rotation for the RotationRecordStore contract and deployment sequence.
Recovery helper APIs
RecoveryCode
| Member | Result |
|---|---|
entropyBits |
192 |
generate() |
A cryptographically random ownfold- prefixed code or CryptoEngineError. |
parse(input) |
The exact valid code or InvalidInputError. |
Parsing is strict and does not normalize partial or mistyped codes. Generated temporary bytes are cleared after use. The application owns display, confirmation, printing, password-manager, and clipboard behavior.
RecoveryKitFile
| Member | Behavior |
|---|---|
parse(contents) |
Strictly parses a JSON string into RecoveryKitV1. |
read(file) |
Reads a browser File, then applies the same strict parser and size ceiling. |
download(kit, filename?) |
Downloads canonical JSON; default name includes the vault ID. |
download is the only helper that performs a UI-adjacent side effect. It does not mark recovery
verified. The application must ask the user to select the saved file and pass its contents to the
verification operation.
Storage API
load() => Promise<Result$1<LocalVaultRecord | null, StorageAdapterError>>
() => Promise<Result$1<LocalVaultRecord | null, StorageAdapterError>>save(record: LocalVaultRecord) => Promise<Result$1<void, StorageAdapterError>>
(record: LocalVaultRecord) => Promise<Result$1<void, StorageAdapterError>>loadPendingPairing() => Promise<Result$1<PendingPairingIdentity | null, StorageAdapterError>>
() => Promise<Result$1<PendingPairingIdentity | null, StorageAdapterError>>savePendingPairing(record: PendingPairingIdentity) => Promise<Result$1<void, StorageAdapterError>>
(record: PendingPairingIdentity) => Promise<Result$1<void, StorageAdapterError>>clearPendingPairing() => Promise<Result$1<void, StorageAdapterError>>
() => Promise<Result$1<void, StorageAdapterError>>loadPendingRecoveryKitReplacement() => Promise<Result$1<PendingRecoveryKitReplacement | null, StorageAdapterError>>
() => Promise<Result$1<PendingRecoveryKitReplacement | null, StorageAdapterError>>savePendingRecoveryKitReplacement(record: PendingRecoveryKitReplacement) => Promise<Result$1<void, StorageAdapterError>>
(record: PendingRecoveryKitReplacement) => Promise<Result$1<void, StorageAdapterError>>clearPendingRecoveryKitReplacement() => Promise<Result$1<void, StorageAdapterError>>
() => Promise<Result$1<void, StorageAdapterError>>loadPendingRotation() => Promise<Result$1<PendingRotationIdentity | null, StorageAdapterError>>
() => Promise<Result$1<PendingRotationIdentity | null, StorageAdapterError>>savePendingRotation(record: PendingRotationIdentity) => Promise<Result$1<void, StorageAdapterError>>
(record: PendingRotationIdentity) => Promise<Result$1<void, StorageAdapterError>>clearPendingRotation() => Promise<Result$1<void, StorageAdapterError>>
() => Promise<Result$1<void, StorageAdapterError>>clear() => Promise<Result$1<void, StorageAdapterError>>
() => Promise<Result$1<void, StorageAdapterError>>Main vault, pending pairing, pending Recovery Kit replacement, and pending rotation state have
separate load/save/clear operations so interrupted workflows remain recoverable. Every method
returns StorageAdapterError; a custom implementation must parse persisted values on read rather
than trusting its own previous writes.
IndexedDbVaultStorage(databaseName?)
The default database name is ownfold-vault-v1; the current IndexedDB schema version is managed by
the package. Supply a custom name to isolate multiple test/application profiles on one origin, not
to create one database per user session.
The implementation stores structured-cloneable protected device material and canonical serialized
domain values in one object store. clear() clears Ownfold local state in that named database only.
Lifecycle channel API
createBroadcastVaultLifecycleChannel(name) implements VaultLifecycleChannel with the browser
BroadcastChannel API. The client uses it to notify sibling tabs about lock and lifecycle changes.
post(message: VaultLifecycleMessage) => void
(message: VaultLifecycleMessage) => voidsubscribe(listener: (message: unknown) => void) => () => void
(listener: (message: unknown) => void) => () => voidclose() => void
() => voidVaultLifecycleMessage.parse(input) validates untrusted channel messages. Unknown, malformed, or
self-originated messages must not mutate state. A custom channel factory must support subscribe,
post, and close semantics without treating delivery as durable storage.
Worker engine API
workerCryptoWorkerPort
CryptoWorkerPortoperationTimeoutMs?number
numbercreateWorkerCryptoEngine(options) and new WorkerCryptoEngine(options) implement CryptoEngine
over a dedicated worker port. operationTimeoutMs defaults to 60 seconds and must be a positive safe
integer. A worker error, message-deserialization failure, timeout, or invalid response fails the
engine closed with a typed error.
WorkerCryptoEngine.dispose() removes listeners, terminates the worker, rejects pending operations,
and invalidates worker root-key references. Call it when replacing the engine or destroying the
client that exclusively owns it.
The worker entry point exports exposeOwnfoldCryptoWorker({ cryptoEngine? }). It must run in a
dedicated Worker global and returns a cleanup function that removes its message listener. Supplying
cryptoEngine is a test/custom-engine seam; production defaults to the sodium engine.
Storage failures and private browsing
IndexedDbVaultStorage uses database ownfold-vault-v1 by default. Browsers may deny, evict, or
partition IndexedDB. Surface StorageAdapterError as a restore-oriented failure, not as a generic
retry loop. If state is unavailable but remote metadata exists, the user must restore or pair.
Never fall back to localStorage, cookies, session storage, URL parameters, or a server endpoint
for private key material. A custom VaultStorage must preserve the discriminated local states,
pending-operation checkpoints, structured errors, and atomic write behavior.
Troubleshooting
Encryption returns VaultLockedError
Call initialize(), render the resulting state, and let the user unlock. Do not silently open a
Recovery Kit or cache plaintext while waiting.
Decryption reports authentication or context failure
Verify the immutable namespace, record ID, owner ID, and stored envelope are from the same row. Never retry with weakened or omitted context.
Onboarding is stuck in sync-pending
Preserve the locally generated Recovery Kit and call resumeSync(). Do not call createVault()
again or overwrite local state.
A paired browser has no local Recovery Kit
Unlock with its device envelope. Recovery Kit replacement and recovery-secret unlock require importing the user’s saved kit; pairing intentionally does not copy the kit.