# Wallet Attached Storage Client _(@interop/was-client)_

[![Node.js CI](https://github.com/interop-alliance/was-client/workflows/CI/badge.svg)](https://github.com/interop-alliance/was-client/actions?query=workflow%3A%22CI%22)
[![NPM Version](https://img.shields.io/npm/v/@interop/was-client.svg)](https://npm.im/@interop/was-client)

> A developer-friendly client for Wallet Attached Storage (WAS) servers, with a
> database-driver-inspired navigational API over zcap-authorized HTTP.

## Table of Contents

- [Background](#background)
- [Install](#install)
- [Usage](#usage)
  - [Creating a client (signer + zcapClient)](#creating-a-client-signer--zcapclient)
  - [The handle model](#the-handle-model)
  - [Spaces](#spaces)
  - [Collections](#collections)
  - [Resources: JSON and binary](#resources-json-and-binary)
  - [Delegation and sharing](#delegation-and-sharing)
  - [Revoking a capability](#revoking-a-capability)
  - [Public sharing and access-control policies](#public-sharing-and-access-control-policies)
  - [Resource metadata](#resource-metadata)
  - [Collection metadata](#collection-metadata)
  - [Conditional writes (optimistic concurrency)](#conditional-writes-optimistic-concurrency)
  - [Storage introspection: backends and quotas](#storage-introspection-backends-and-quotas)
  - [Registering a Bring-Your-Own-Storage backend](#registering-a-bring-your-own-storage-backend)
  - [Encrypted collections (EDV-over-WAS): pass-through encryption via the WAS client (recommended)](#encrypted-collections-edv-over-was-pass-through-encryption-via-the-was-client-recommended)
  - [Cross-replica sync](#cross-replica-sync)
  - [Resource logs (co-managed key resources)](#resource-logs-co-managed-key-resources)
  - [Export and import](#export-and-import)
  - [The manual-request escape hatch](#the-manual-request-escape-hatch)
- [Errors and the 404/null caveat](#errors-and-the-404null-caveat)
- [Contribute](#contribute)
- [License](#license)

## Background

The WAS protocol exposes a general purpose database-like container model --
`SpacesRepository > Space > Collection > Resource` -- over HTTP, authorized with
[Authorization Capabilities (zcaps)](https://w3c-ccg.github.io/zcap-spec/).

`@interop/was-client` wraps that `ZcapClient` and exposes the containment model
through cheap, lazy navigational handles modeled on a document store's DX
(`client > db > collection`), using WAS-specific verbs
(`add`/`get`/`put`/`list`/`delete`) rather than `insertOne`/`findOne` (WAS has
no query-by-filter yet).

| Document db driver                  | WAS client                                 |
| ----------------------------------- | ------------------------------------------ |
| `new Client(url)`                   | `new WasClient({ serverUrl, zcapClient })` |
| `client.db('app')`                  | `was.space(spaceId)`                       |
| `db.collection('users')`            | `space.collection(collectionId)`           |
| `collection.insertOne(doc)`         | `collection.add(doc)`                      |
| `collection.findOne({ _id })`       | `collection.get(resourceId)`               |
| `collection.replaceOne({ _id }, d)` | `collection.put(resourceId, data)`         |
| `collection.find().toArray()`       | `collection.list()`                        |
| `collection.deleteOne({ _id })`     | `collection.resource(resourceId).delete()` |

See also:

- [CONTRIBUTING.md](CONTRIBUTING.md) -- editor setup and code style conventions
- [AGENTS.md](AGENTS.md) -- toolchain, tests, and conventions for coding agents

## Install

- Node.js 24+ is recommended.

```
pnpm install @interop/was-client
```

## Usage

### Creating a client (signer + zcapClient)

A `WasClient` signs every request with a key you control. The key is held by an
ezcap `ZcapClient`, which you build from a `did:key` identity. You will need two
companion packages alongside this one (this library already depends on
`@interop/ed25519-signature`):

```
pnpm install @interop/ezcap @interop/did-method-key @interop/ed25519-verification-key
```

The primary form wraps a `ZcapClient` you build yourself. The `did:key` driver
generates a key pair and a matching DID document, wiring the signer's
`id`/`controller` correctly:

```ts
import { ZcapClient } from '@interop/ezcap'
import * as didKey from '@interop/did-method-key'
import { Ed25519Signature2020 } from '@interop/ed25519-signature'
import { Ed25519VerificationKey } from '@interop/ed25519-verification-key'
import { WasClient } from '@interop/was-client'

// 1. Generate a did:key identity (didDocument + keyPairs).
const didKeyDriver = didKey.driver()
didKeyDriver.use({ keyPairClass: Ed25519VerificationKey })
const { didDocument, keyPairs } = await didKeyDriver.generate()

// 2. Build the ezcap ZcapClient (it holds the signer and signs every request).
const zcapClient = new ZcapClient({
  didDocument,
  keyPairs,
  SuiteClass: Ed25519Signature2020
})

// 3. Wrap it.
const was = new WasClient({ serverUrl: 'https://was.example', zcapClient })
```

If you already have a single signer, `WasClient.fromSigner()` builds the
`ZcapClient` internally (using the `Ed25519Signature2020` suite). A signer is
any object with `{ id, sign() }`; here we get one from a generated key. The
signer's `id` must be a `did:key` so the server can resolve and verify it:

```ts
import { Ed25519VerificationKey } from '@interop/ed25519-verification-key'
import { WasClient } from '@interop/was-client'

// Pass a 32-byte `seed` for a deterministic key, or omit it for a random one.
const keyPair = await Ed25519VerificationKey.generate({ seed })
keyPair.controller = `did:key:${keyPair.fingerprint()}`
keyPair.id = `${keyPair.controller}#${keyPair.fingerprint()}`

const was = WasClient.fromSigner({
  serverUrl: 'https://was.example',
  signer: keyPair.signer()
})
```

The `seed` is where a passphrase-, stored-secret-, or KMS-derived key plugs in:
deriving the same 32-byte seed yields the same DID, and therefore access to the
same spaces. (Apps with user accounts often derive the signer from a passphrase
via `CapabilityAgent.fromSecret()` from `@digitalbazaar/webkms-client` -- not
required, just a common alternative.)

`serverUrl` is the base for both URL building and zcap `invocationTarget`s, so
the "server URL must equal the invocation target host:port" constraint holds by
construction.

### The handle model

The client exposes the WAS containment model
(`SpacesRepository > Space > Collection > Resource`) as navigational handles.
Handles are lazy and synchronous to obtain -- only the verb methods hit the
network. Lazy chains never throw: `was.space(x).collection(y)` does no I/O and
just accumulates URL context. Existence is checked on the first network verb.

```ts
const space = await was.createSpace({ name: 'Home' })

const collection = await space.createCollection({
  name: 'Verifiable Credentials'
})

await collection.put('vc-1', {
  type: ['VerifiableCredential'],
  name: 'Diploma'
})
const vc = await collection.get('vc-1') // parsed JSON object, or null on a miss

await collection.resource('vc-1').delete() // delete one resource by id
await space.delete() // delete the whole space (idempotent)
```

`delete()` is uniform at every level, takes no argument, and always deletes the
thing the handle points at -- so there is no "delete the collection" vs "delete
one item" footgun. The next sections cover each level in turn.

### Spaces

A Space is the top-level container, created from the spaces repository. The
server requires a `name`; `controller` defaults to the client's own DID, and the
server generates the id unless you pass one.

```ts
const space = await was.createSpace({ name: 'Home' }) // POST /spaces/

// Lazy handle to an existing space by id -- no I/O until a verb runs.
const same = was.space(space.id)

// Read the Space Description (null if missing or not visible to you).
const desc = await space.describe() // { id, type: ['Space'], name, controller } | null

// Upsert: merges the given fields over the current description.
await space.configure({ name: 'Home (renamed)' })

await space.delete() // idempotent
```

List the spaces in the repository visible to your signer with
`was.listSpaces()`. It returns a `{ url, totalItems, items }` listing holding
only the spaces whose controller your invocation is authorized for; an
unauthorized caller gets an empty list rather than an error. To enumerate what
is _inside_ a space, use `space.collections()` (below).

```ts
const { totalItems, items } = await was.listSpaces()
// items: [{ id, url, name? }, ...]
```

### Collections

A Collection lives inside a Space and holds resources. WAS does not auto-create
parents, so `createCollection` throws `NotFoundError` if the space does not
exist. The server generates the id unless you pass one (a handful of reserved
ids are rejected).

```ts
// Create.
const collection = await space.createCollection({
  name: 'Verifiable Credentials'
})

// Lazy handle to an existing collection by id.
const same = space.collection(collection.id)

// Read the Collection Description (null if missing or not visible).
const desc = await collection.describe() // { id, type: ['Collection'], name } | null

// Update (upsert; merges over the current description).
await collection.configure({ name: 'Credentials' })

// List the collections in a space.
const collections = await space.collections()
// { url, totalItems, items: [{ id, name, url }, ...] } | null

// List the resources inside this collection. Transparently follows the
// server's `next` pagination links, buffering every page into one listing.
const resources = await collection.list()
// { id, url, totalItems, items: [{ id, url, contentType }, ...], ... } | null

// For a large collection, stream one page (or item) at a time instead of
// buffering the whole thing -- follows `next` on demand, stops early on `break`.
for await (const item of collection.listItems()) {
  // item: { id, url, contentType, name? }
}
// `collection.listPages()` yields whole pages if you'd rather page yourself.

await collection.delete() // deletes the whole collection; idempotent
```

To delete a single resource instead of the whole collection, use
`collection.resource(id).delete()`.

The Description's `name` is server-visible plaintext. An encrypted collection
leaves it unpopulated by convention and carries its name and tags on the
[Collection metadata](#collection-metadata) surface instead, where they are
encrypted.

An application that provisions a collection can record who it was provisioned
for, with the optional `generator` (the application's DID) and `generatorOrigin`
(the Web origin that DID was bound to) description fields. Both are accepted at
create time and stay writable afterwards, so an existing collection can be
backfilled; they are controller assertions the server persists but does not
verify.

```ts
await space.createCollection({
  name: 'App Notes',
  generator: 'did:key:z6Mk...',
  generatorOrigin: 'https://app.example'
})

// Backfill an existing collection (other fields merge forward unchanged).
await space.collection('notes').configure({
  generator: 'did:key:z6Mk...',
  generatorOrigin: 'https://app.example'
})
```

### Resources: JSON and binary

A Resource is a JSON object or binary blob keyed by id within a Collection. Use
`add()` for a server-generated id or `put(id, ...)` to create-or-replace at a
known id (both throw `NotFoundError` if the parent collection is missing):

```ts
// Server-generated id; returns { id, url, contentType? }.
const added = await collection.add({
  type: ['VerifiableCredential'],
  name: 'Diploma'
})

// Create or replace at a known id (upsert).
await collection.put('vc-1', {
  type: ['VerifiableCredential'],
  name: 'Diploma'
})

const vc = await collection.get('vc-1') // parsed JSON object, or null on a miss
await collection.resource('vc-1').delete() // idempotent
```

Writes detect the payload: a plain object/array is sent as JSON; a
`Blob`/`Uint8Array`/`Buffer` is sent as binary, with the content-type taken from
`options.contentType`, the `Blob.type`, or `application/octet-stream`.

```ts
// JSON
await collection.put('doc', { hello: 'world' })

// Binary
const bytes = new TextEncoder().encode('plain text body')
await collection.put('note.txt', bytes, { contentType: 'text/plain' })

const resource = collection.resource('note.txt')
await resource.get() // a Blob (whose .type carries the content-type)
await resource.getText() // 'plain text body'
await resource.getBytes() // Uint8Array
```

Reads auto-parse: `get()` returns a parsed object for a JSON content-type and a
`Blob` otherwise; `getText()` / `getBytes()` are explicit escape hatches.

A write value is a JSON object/array or binary (`Blob`/`Uint8Array`) -- the
`ResourceData` type. A top-level JSON primitive (a bare `string`, `number`,
`boolean`, or `null`) is **not** accepted; it is a compile-time error. To store
one, either wrap it in an object (`put('greeting', { value: 'hello' })`) or
write it as binary via a `Blob`:

```ts
await collection.put('greeting', new Blob(['hello'], { type: 'text/plain' }))
```

### Delegation and sharing

`was.grant(...)` is the general delegation primitive; `space.grant(...)` and
`collection.grant(...)` are sugar that prefill the grant `target` with the
handle's URL. The recipient rebuilds access from the received zcap with
`fromCapability()`.

```ts
// Alice grants Bob read access to a resource.
const added = await collection.add({ secret: 'value' })
const zcap = await was.grant({
  to: bobDid,
  actions: ['GET'], // HTTP verbs: 'GET' | 'PUT' | 'POST' | 'DELETE'
  target: added.url
})

// Bob, holding the zcap, rebuilds a handle at the right depth.
const handle = bobWas.fromCapability(zcap) // a Resource here
await handle.get() // succeeds; a write would be denied by the GET-only grant
```

Actions are HTTP verbs (`GET` / `PUT` / `POST` / `DELETE`). The WAS server
authorizes on these case-sensitively (uppercase), but `grant()` also accepts the
lowercase forms and normalizes them to uppercase in the signed zcap -- so
`actions: ['get']` still validates server-side.

### Revoking a capability

`space.revoke(zcap)` undoes a `grant()`, so a leaked capability need not be
waited out to its `expires`. `was.revoke(zcap)` is the same operation with the
Space derived from the capability.

```ts
const zcap = await collection.grant({ to: appDid, actions: ['GET', 'PUT'] })
// ... the capability leaks ...
await space.revoke(zcap) // from now on the capability is rejected
```

Two parties may revoke: the Space's controller, and any controller in the
capability's own delegation chain -- so an application can revoke the capability
it holds, without being granted anything extra. Anyone else gets a
`NotFoundError`, WAS's mask for "not visible to you".

Three properties are easy to get wrong:

- **Revocation is Space-scoped.** There is no global or cross-Space revocation;
  submitting a capability rooted in another Space throws `ValidationError`.
- **It withdraws the capability, not a policy grant.** Access-control policies
  are permissive, so a `PublicCanRead` target stays world-readable after you
  revoke a capability naming it. What dies is the capability: on such a target
  the revoked zcap's read still succeeds while its write does not.
- **It is prospective, and not idempotent.** A revoked reader of an encrypted
  Collection keeps the keys for ciphertext it already fetched. And revoking an
  already-revoked capability throws `ValidationError`: the server reports it
  with the same 400 it uses for a tampered or expired capability, so the client
  cannot tell them apart and does not swallow any of them. Catch
  `ValidationError` if you want revoking twice to be a no-op.

### Public sharing and access-control policies

A Space, Collection, or Resource can carry an access-control **policy** that
grants read access beyond capabilities -- most commonly making it world-readable
("share via public link"). The policy methods live on all three handles:

```ts
// Make a whole collection world-readable (the "create public link" case).
await collection.setPublic() // sugar for setPolicy({ type: 'PublicCanRead' })

// Anyone (even unauthenticated) can now read its resources.
const link = added.url // hand this URL out; a plain GET resolves it

// Inspect or revoke.
const policy = await collection.getPolicy() // { type: 'PublicCanRead' } | null
const isPublic = await collection.isPublic() // true if its own policy is PublicCanRead
await collection.clearPolicy() // revert to capability-only access (idempotent)

// setPolicy() is the generic, forward-compatible primitive; setPublic() is sugar.
await space.setPolicy({ type: 'PublicCanRead' }) // inherited by all contents
await resource.setPublic() // a single public resource
```

Policies are resolved most-specific-first (Resource over Collection over Space)
and are permissive-only -- they broaden access, never restrict a valid
capability holder. Managing a policy is a controller-level operation. Discover a
policy via `space.linkset()` / `collection.linkset()` (RFC9264) or the `linkset`
property on a description.

`isPublic()` is a read-only convenience that returns `true` when the Space,
Collection, or Resource has a `{ type: 'PublicCanRead' }` policy -- that is,
when it has been made public via `setPublic()` (or an equivalent `setPolicy()`
call). It's meant to drive data-browser style UI, to show a "This
space(/collection/resource) has been shared publicly" type of icon.

#### Consuming public links (unauthenticated reads)

The flip side of `setPublic()`: reading a `PublicCanRead` resource or collection
with no authorization, by its URL. These use an unsigned plain `fetch` (no
capability invocation), so they work for a consumer who only holds the link.

```ts
// Fetch a single public resource (auto-parses JSON, returns binary as a Blob).
const doc = await was.publicRead({
  resourceUrl: 'https://was.example/space/s/c/r'
}) // Json | Blob | null

// List a public collection -- e.g. a blog published as a public-read collection.
const listing = await was.publicListCollection({
  collectionUrl: 'https://was.example/space/s/c'
}) // ResourceListing | null

// Or stream a large public collection one item/page at a time:
for await (const item of was.publicListCollectionItems({
  collectionUrl: 'https://was.example/space/s/c'
})) {
  // item: { id, url, contentType, name? }
}
// (`was.publicListCollectionPages(...)` yields whole pages.)
```

Both follow the read-method 404/null caveat: a missing or non-public target
resolves to `null`.

### Resource metadata

Each Resource has a metadata object at its reserved `/meta` path: server-managed
properties (`contentType`, `size`, optional `createdAt` / `updatedAt`) plus a
user-writable `custom` object (`name` and `tags`).

```ts
const resource = collection.resource('vc-1')

const meta = await resource.meta() // ResourceMetadata | null (null on a miss)

// setMeta() is a full replacement of `custom`; omitted properties are cleared.
await resource.setMeta({ custom: { name: 'Diploma', tags: { year: '2026' } } })

// setName() / setTags() are read-modify-write sugar that preserve the other.
await resource.setName('Renamed diploma') // keeps existing tags
await resource.setTags({ status: 'verified' }) // keeps existing name
```

The `custom.name` is the same value surfaced as a resource's `name` in
collection listings; updating one updates the other.

### Collection metadata

A Collection has the same metadata surface at its own reserved `/meta` path:
server-managed properties (`createdAt` / `updatedAt` / `createdBy`) plus a
user-writable `custom` object (`name` and `tags`). A server without the endpoint
surfaces its 501 as `NotImplementedError`.

```ts
const meta = await collection.meta() // CollectionMetadata | null (null on a miss)

// setMeta() is a full replacement of `custom`; omitted properties are cleared.
await collection.setMeta({ custom: { name: 'Vault', tags: { app: 'wallet' } } })

// setName() / setTags() are read-modify-write sugar that preserve the other.
await collection.setName('Renamed vault') // keeps existing tags
await collection.setTags({ app: 'wallet' }) // keeps existing name

// Conditional metadata write, against a `conditional-writes` backend.
await collection.setMeta({ custom: { name: 'Vault' } }, { ifMatch: meta?.etag })
// ...or write only if no metadata is set yet:
await collection.setMeta({ custom: { name: 'Vault' } }, { ifNoneMatch: true })
```

A failed precondition throws `PreconditionFailedError` (412). This `/meta` ETag
(`metaVersion`) is versioned independently of the Collection Description's ETag
and of every Resource's versions: writing one never bumps the other.

On an encrypted collection `custom` is encrypted into an envelope before it is
sent, so `name` / `tags` are never stored as server-visible plaintext, and
`meta()` decrypts them back for a keyed reader. This is the encrypted
collection's name/tags surface: by convention the plaintext Description `name`
is left unpopulated there.

### Conditional writes (optimistic concurrency)

Against a backend that advertises the `conditional-writes` feature (see below),
a Resource carries a strong **`ETag`** validator that changes on every write.
Use it to prevent the lost-update problem -- two clients that both read version
_N_ and each write _N+1_, the second silently clobbering the first.

```ts
const { etag } = await collection.put('doc', { v: 1 }) // writes return the ETag
const meta = await collection.resource('doc').meta() // meta().etag also carries it

// Update-if-unchanged: succeeds only if `doc` is still at `etag`, else throws
// PreconditionFailedError (HTTP 412).
await collection.put('doc', { v: 2 }, { ifMatch: etag })

// Create-if-absent: succeeds only if `doc` does not yet exist (else 412).
await collection.put('new-doc', { v: 1 }, { ifNoneMatch: true })

// Delete-if-unchanged.
await collection.resource('doc').delete({ ifMatch: someEtag })
```

Recover from a `PreconditionFailedError` by re-reading the current `etag`,
re-applying your change on top of the new version, and retrying.

On an **encrypted collection** this is automatic: the EDV codec advances the
document `sequence` and pins each write to the current ETag for you, so a stale
write surfaces as a `PreconditionFailedError` (the EDV `sequence` becomes
enforced rather than advisory). The explicit `ifMatch` / `ifNoneMatch` options
above are for plaintext collections.

### Storage introspection: backends and quotas

A Space can report the storage backends available to it and a per-backend usage
report. Both are optional server features (a server without them surfaces a
`NotImplementedError`); both follow the read-method 404/null caveat.

```ts
const backends = await space.backends() // BackendDescriptor[] | null
const report = await space.quotas() // SpaceQuotaReport | null
// report.backends[i]: { id, state, usageBytes, limit, restrictedActions, ... }
```

A Collection can likewise report the backend it is stored on and its own usage,
scoped to that backend (same optional-feature and 404/null caveats).

```ts
const backend = await collection.backend() // BackendDescriptor | null
const usage = await collection.quota() // BackendUsage | null
// usage: { id, state, usageBytes, limit, restrictedActions, measuredAt, ... }
```

A `BackendDescriptor`'s optional `features` array advertises optional **server
affordances** -- things the backend actively does beyond the baseline read/write
API (e.g. `conditional-writes`, `blinded-index-query`, `chunked-streams`). An
absent token means the backend makes no claim to it, so treat it as unsupported
rather than assuming a default. (Client-side encryption is _not_ a backend
feature -- see below.)

```ts
const backend = await collection.backend()
if (backend?.features?.includes('conditional-writes')) {
  // backend enforces If-Match / If-None-Match write preconditions
}
```

### Registering a Bring-Your-Own-Storage backend

Beyond the server's built-in `default` backend, the Space controller can
register an `external` ("Bring Your Own Storage") backend -- e.g. a wallet
connecting a user's own Google Drive. Registration is a controller-authorized
write: the body carries the secret-bearing `connection` material (an OAuth
authorization code or refresh token), and the server stores it and returns the
**sanitized** descriptor (never the secrets).

```ts
const descriptor = await space.registerBackend({
  id: 'gdrive-personal', // unique within the Space
  name: 'My Google Drive',
  provider: 'google-drive', // selects the server-side adapter
  storageMode: ['document', 'blob'],
  connection: {
    kind: 'oauth2-google',
    authorizationCode: '4/0Ab...', // one-time PKCE code (or a refreshToken)
    redirectUri: 'https://wallet.example/oauth/callback'
  }
})
// descriptor.connection: { kind, status: 'registered', account?, scope?, ... }
```

Once registered, select it on a Collection by id; reads of the backend reflect
its connection `status` (`registered` | `connected` | `expired` | `revoked` |
`unreachable`), which a storage-management UI uses to prompt re-consent:

```ts
await space.createCollection({
  id: 'photos',
  backend: { id: 'gdrive-personal' }
})

const [, gdrive] = (await space.backends()) ?? []
if (gdrive?.connection?.status === 'expired') {
  // re-consent: swap in fresh connection material (create-or-replace by id)
  await space.updateBackend({
    id: 'gdrive-personal',
    provider: 'google-drive',
    connection: { kind: 'oauth2-google', authorizationCode: '4/0Cd...' }
  })
}

// Deregister (idempotent): forgets the record and its stored connection.
await space.deregisterBackend('gdrive-personal')
```

`registerBackend()` throws a `ConflictError` if the `id` already exists or the
server does not permit the `provider`; `updateBackend()` returns the descriptor
when it created a record and `null` when it replaced one in place (the server
sends no body on an in-place replace).

> A registered backend's record exists immediately, but whether its connection
> can actually serve bytes depends on the server having a live provider adapter
> for it. Until then it is registered but inert (`status: 'registered'`).

### Encrypted collections (EDV-over-WAS): pass-through encryption via the WAS client (recommended)

This is the recommended way to use encrypted collections. For the low-level
alternative -- driving an `EdvClientCore` directly via `WasTransport`, which is
also how to stream a blob you do not want to hold in memory -- see
[docs/edv-client-core-usage.md](docs/edv-client-core-usage.md).

Client-side end-to-end encryption is a per-collection concern -- **not** a
backend feature (an encrypted document is opaque JSON any document backend
stores faithfully). Two things drive it, kept separate:

- **Policy** (is this collection encrypted?) is declared on the collection
  itself: `createCollection({ encryption: { scheme: 'edv' } })` writes a
  non-secret `encryption` descriptor to the Collection Description. Any
  authorized reader -- including a delegated consumer that did **not** create
  the collection -- discovers it by reading the Description, so it knows to
  decrypt.
- **Keys** come from an `encryption` provider you pass to `WasClient` (built
  from the opt-in `@interop/was-client/edv` subpath, so plaintext consumers
  never pull the crypto graph). It is a pure **keystore**: `resolveKeys` returns
  the collection's keys, which live in your wallet. The server only ever stores
  opaque JWE envelopes.

The ordinary `Collection`/`Resource` handles then transparently encrypt on write
and decrypt on read for any collection the descriptor (or an override) declares
encrypted.

```ts
import { WasClient } from '@interop/was-client'
import { createEdvEncryption } from '@interop/was-client/edv'

const encryption = createEdvEncryption({
  // The keystore: return the collection's keys (from your wallet).
  async resolveKeys({ spaceId, collectionId }) {
    return { keyAgreementKey, keyResolver }
  }
})
const was = WasClient.fromSigner({ serverUrl, signer, encryption })

// Declare the collection encrypted (writes the descriptor). The returned handle is
// pre-seeded, so the first write encrypts with no extra round-trip.
const vault = await was
  .space(spaceId)
  .createCollection({ id: 'vault', encryption: { scheme: 'edv' } })
const { id } = await vault.add({ secret: 'hello' }) // encrypted; id is an EDV id
const back = await vault.get(id) // { secret: 'hello' } -- decrypted

// A consumer that did not create it discovers the descriptor and decrypts with its
// own keys -- no override needed; one cached read of the Description:
const same = was.space(spaceId).collection('vault')
await same.get(id) // reads the descriptor, then decrypts
```

The switch is the **descriptor**: a handle encrypts a collection when its
Description declares `encryption` (resolution reads the Description once, then
caches -- no round-trip for plaintext-only clients or when an override is set).
Keys are then **required**: if the collection is declared encrypted but your
keystore returns no keys, reads/writes throw `EncryptionError` (fail-closed) --
they never silently fall back to plaintext.

**Per-handle override (escape hatch).** Pass `encryption` in the handle options
to force the decision and skip the Description read -- `{ scheme: 'edv' }` (keys
from the keystore), `{ scheme: 'edv', keys }` (keys inline), or `'plaintext'`:

```ts
const vault = was.space(spaceId).collection('vault', {
  encryption: { scheme: 'edv' }
})
```

**Migrating a pre-descriptor vault** (created before the descriptor existed,
keys-only): re-declare it once with
`collection.configure({ encryption: { scheme: 'edv' } })` (the descriptor is
set-once: declaring it on a collection that lacks one is allowed, changing or
clearing an existing one is rejected). Until then, a per-handle override reads
it correctly.

Encrypted collections are a **stricter contract**, not a drop-in (documents-only
scope for now):

- **Ids.** `add()` mints an EDV id (a `z`-prefixed multibase value used verbatim
  as the WAS resource id). `put(id, ...)` accepts only an EDV-format id; a
  human-readable id is rejected (it would leak onto the URL) -- carry a
  human-readable label inside the encrypted content instead. By default the
  minted id is random (the classic mutable-document model); pass
  `createEdvEncryption({ idDerivation: 'content' })` to derive it from the
  envelope's JWE ciphertext instead, making documents content-addressed -- the
  id is then stable across replicas (no mapping table), at the cost of
  immutability (an "update" is delete-old + add-new).
- **Metadata.** `resource.setName()` / `setTags()` / `setMeta()` work on an
  encrypted collection: the user-writable `custom` (`name` / `tags`) is
  encrypted into an envelope before it is sent, so the server never sees the
  plaintext, and `meta()` decrypts it back for a keyed reader. The `/meta`
  endpoint has its own ETag (`metaVersion`), independent of the content ETag.
  The same pair at Collection level (`collection.setName()` / `setTags()` /
  `setMeta()` / `meta()`) is where an encrypted collection carries its own name
  and tags, since the Description's plaintext `name` is left unpopulated; the
  collection-level envelope binds no resource id, and a resource-bound envelope
  served into that slot is refused.
- **Binary.** A `Blob`/`Uint8Array` up to `maxBlobBytes` (512 KiB by default) is
  encrypted as a single document. A larger one is routed automatically by
  `add()` to the chunked-stream path: one document plus its chunk resources,
  read back transparently by `get()`. That needs the backend's `chunked-streams`
  feature, checked before anything is written (`NotSupportedError` otherwise).
  Two limits apply. Auto-routing is an `add()` affordance -- `put(id, bigBlob)`
  is refused, since replacing an existing document's chunks is not automated.
  And a content-addressed collection (`idDerivation: 'content'`) is refused too:
  a chunked write stores the document twice, so no single ciphertext derives its
  id. Tune the threshold and the chunk size with
  `createEdvEncryption({ maxBlobBytes, chunkSize })`.
- **Raw reads.** `get()` decrypts; the `getText()` / `getBytes()` escape hatches
  do not (they return the stored representation).

#### Searching an encrypted collection

An encrypted collection can be searched by attribute without the server ever
learning the attribute names or their values: the client blinds both into HMAC
tokens (equal plaintext blinds to equal tokens, and nothing else), stores them
alongside the ciphertext, and blinds the query the same way. The server compares
opaque strings.

The blinding key is installed with the collection's first key epoch or never --
retro-fitting one would leave every already-written document unindexed -- so ask
for it at provisioning time:

```ts
await ensureFirstEpoch({
  collection: declared,
  recipients: [ownerRecipient({ keyAgreementKey })],
  blindedIndex: true // searchable: a property fixed at birth
})
```

Then declare what is searchable and search it:

```ts
const vault = was.space(spaceId).collection('vault')
await vault.declareIndex({ attribute: 'content.type' })

await vault.add({ type: 'note', title: 'alpha' })

const page = await vault.find({ equals: { 'content.type': 'note' } })
// { items: [{ id, data: { type: 'note', title: 'alpha' } }], hasMore: false }

const { count } = await vault.find({
  equals: { 'content.type': 'note' },
  count: true
})
```

Attribute names are dotted paths rooted at `content` or `meta` (the document's
own `meta`, which carries the content type and encoding -- not the WAS `/meta`
name/tags, which live in a separate envelope and are deliberately not indexed).
Pass an array of names for a compound index, searchable by a leading prefix of
its attributes; `unique: true` makes the server answer a colliding write with
`409`. Give `find()` either `equals` (an array of objects is an OR of
alternatives) or `has` (attribute names a document must carry); page with
`limit` and the returned `cursor`.

Two properties are worth planning around:

- **Declarations are collection state, not app state.** The schema is persisted
  inside the collection's encrypted metadata envelope, so any recipient
  discovers it -- read it with `collection.indexes()`. That is what lets an app
  granted access to an existing collection learn what is queryable; the stored
  tokens cannot teach it, being blinded. Concurrent declarations reconcile
  through the metadata ETag rather than overwriting each other.
- **Declarations are prospective.** A document written before an attribute was
  declared carries no token for it and does not match until it is rewritten
  (each entry's `addedIn` revision records when it became searchable). Searching
  an attribute the schema does not declare throws `ValidationError` rather than
  silently matching nothing.

Requires the collection's backend to advertise `blinded-index-query`; a backend
without it answers `501` (`NotImplementedError`). `find()` / `declareIndex()` on
a plaintext collection throw -- there is no client-side index there.

Note that `collection.setMeta({ custom })` replaces the whole `custom` object,
schema included; the `setName()` / `setTags()` sugar merges instead and leaves
it intact.

### Cross-replica sync

The opt-in `@interop/was-client/sync` subpath supplies everything a wallet needs
to replicate one Space + Collection across devices, with WAS as the primary
copy. It is not a sync engine itself -- it provides the seams a change engine
plugs into:

- **`createWasSyncPort({ was, spaceId, collectionId })`** builds a
  `WasSyncPort`: paged pulls over the collection's `changes` feed (resumable via
  an opaque server-side checkpoint) and conditional pushes
  (`putContent`/`deleteContent`/`putMeta`) guarded by the server's content
  `ETag`. The port moves stored bodies **verbatim** -- for an encrypted
  collection that means the opaque EDV envelope, never plaintext, and the port
  itself never touches keys. A rejected precondition throws
  `WasSyncConflictError` (412); a delete of an already-gone resource throws
  `WasSyncNotFoundError` (404) -- both catchable subtypes of the core
  `PreconditionFailedError` / `NotFoundError`.
- **`DocCipher`** is the per-collection encrypt/decrypt seam sitting above the
  port: it turns a JSON document into its stored body (minting the resource id)
  and back. `createPlaintextDocCipher(...)` is the crypto-free identity
  implementation for a plaintext content-addressed collection;
  `createEdvDocCipher(...)` (from `@interop/was-client/edv`) is the encrypting
  one, built from the collection's key-epoch descriptor (every encrypted
  collection carries one from birth; install epoch[0] at provision time with
  `ensureFirstEpoch`). On a searchable collection, also hand it the stored
  Collection `/meta` value (the `meta` input, or `applyMeta` when the replica's
  copy changes) so pushed documents carry blinded index entries and stay visible
  to `find()`. `createEdvEncryptOnlyDocCipher(...)` is the write-only
  counterpart, built from the descriptor alone with no key-agreement secret
  (writes seal to the current epoch's public key, reconstructed from the epoch
  id); `decrypt` on it refuses with the typed `EncryptOnlyCipherError`.
- **`contentCid(doc)`** and **`deriveSpaceId(controllerDid)`** derive
  content-addressed ids -- `base64url(SHA-256(utf8(JCS-canonicalized JSON)))`,
  unpadded -- so the same logical document (and the same controller) lands on
  the same id on every replica, with no coordination or mapping table.
- **`ensureSpaceAndCollection(...)`** is idempotent provisioning: upsert the
  Space, configure the collection (`edv` or `plaintext`, optionally
  world-readable). Safe to re-run on every connect.

```ts
import {
  createWasSyncPort,
  createPlaintextDocCipher,
  deriveSpaceId,
  ensureSpaceAndCollection,
  WasSyncConflictError
} from '@interop/was-client/sync'

const spaceId = deriveSpaceId(controllerDid) // same Space on every device
await ensureSpaceAndCollection({
  was,
  spaceId,
  controllerDid,
  collectionId: 'notes',
  encryption: 'plaintext'
})

const port = createWasSyncPort({ was, spaceId, collectionId: 'notes' })
const cipher = createPlaintextDocCipher({ collectionId: 'notes' })

// Push: encrypt (mints the id), then write the stored body verbatim.
const { id, envelope } = await cipher.encrypt({ data: { note: 'hello' } })
try {
  await port.putContent({ id, data: envelope, ifNoneMatch: true })
} catch (err) {
  if (!(err instanceof WasSyncConflictError)) {
    throw err
  }
  // 412 on a content-addressed insert: another replica already wrote this
  // exact document (same content id) -- a settled outcome, nothing to merge.
}

// Pull: page through the change feed, resuming from the last checkpoint.
let checkpoint
do {
  const page = await port.query({ checkpoint, limit: 100 })
  for (const doc of page.documents) {
    if (doc._deleted) continue // tombstone
    const data = await cipher.decrypt({ envelope: doc.data })
    // apply to the local replica, recording doc.version for later pushes
  }
  checkpoint = page.checkpoint // null when the page was empty (caught up)
} while (checkpoint)
```

The subpath is crypto-free: importing it never pulls the `./edv` dependency
graph. To sync an encrypted collection, keep the same port and swap in
`createEdvDocCipher` -- the change feed and the port ship the envelope bytes
unchanged either way, so the server never sees plaintext. If the collection
carries a blinded-index key, pass the cipher the stored `/meta` value
(`meta: { custom }`) so its writes emit the same blinded index tokens a
Collection-handle write does; a cipher built without it writes documents
`find()` cannot see until they are rewritten.

### Resource logs (co-managed key resources)

The opt-in `@interop/was-client/log` subpath is the WAS binding of
`@interop/vh-resource-log`'s store port. Resource logs -- the hash-linked, JSON
Lines log format (the encrypted-collections spec's Resource Log Profile)
governing key resources co-managed between a wallet's clients and the storage
server, such as encryption descriptors and key rosters -- otherwise live in that
library: the JSON Lines codec, the `ResourceLogStore` port, the read-back
`confirmAppend`, chain verification, and the chain-head pin. The wire types
(`ResourceLogEntry` et al.) live in `@interop/storage-core`. This subpath
re-exports none of them.

- **`resourceLogStore({ resource })`** -- the port over a WAS Resource (stored
  as `text/jsonl`): read-with-etag of the full log, compare-and-swap append
  conditioned on that etag, and the guarded create of a genesis entry. Appends
  carry the prior lines' bytes forward verbatim. Both writes ride the backend's
  `conditional-writes` feature, which the profile requires; a lost race rethrows
  the library's `ResourceLogConflictError` with the transport's
  `PreconditionFailedError` as `cause`.

### Export and import

```ts
const archive = await space.export() // Uint8Array (application/x-tar)
const stats = await otherSpace.import(archive)
// { collectionsCreated, collectionsSkipped, resourcesCreated, resourcesSkipped,
//   policiesCreated, policiesSkipped }
```

`export()` buffers the whole tar archive into memory, which is the simplest
shape for small spaces. Two additive companions cover the large-space and
container cases:

```ts
// Constant memory: pipe the archive straight to a file / upload / compressor,
// without buffering the whole space into RAM. The stream must be consumed or
// cancelled (an abandoned stream holds the connection open).
const stream = await space.exportStream() // ReadableStream<Uint8Array>

// A Blob typed `application/x-tar`, the direct companion to import(). Copying a
// space is a one-liner:
const stats = await otherSpace.import(await space.exportBlob())
```

Pick by size: `exportStream()` for a large space, `exportBlob()` when you want
the `import()` companion or a browser download, and `export()` for a small space
or when you need the bytes anyway. (In Node a Blob is memory-backed, so
`exportBlob()` does not lower peak memory versus `export()`; browsers may spill
large Blobs to disk.)

### The manual-request escape hatch

`was.request(...)` mirrors ezcap's generic `request()` for hand-built calls. As
a deliberate escape hatch it returns the raw `HttpResponse` and throws raw
ezcap/ky errors -- it does not apply the null-on-404 or typed-error
conveniences.

```ts
const response = await was.request({ path: `/space/${spaceId}`, method: 'GET' })
```

## Errors and the 404/null caveat

Read methods (`describe`/`get`/`list`) return `null` on a 404, following
MongoDB's `findOne` semantics. **WAS returns 404 for both not-found and
unauthorized**, so `null` means "not visible to you" rather than strictly "does
not exist". Write/delete methods throw a typed error instead.

| Status | Read methods           | Write / delete methods |
| ------ | ---------------------- | ---------------------- |
| 404    | `null`                 | `NotFoundError`        |
| 400    | `ValidationError`      | `ValidationError`      |
| 401    | `AuthRequiredError`    | `AuthRequiredError`    |
| 409    | `ConflictError`        | `ConflictError`        |
| 413    | `PayloadTooLargeError` | `PayloadTooLargeError` |
| 501    | `NotImplementedError`  | `NotImplementedError`  |
| 507    | `QuotaExceededError`   | `QuotaExceededError`   |
| 5xx    | `WasServerError`       | `WasServerError`       |

All error classes extend `WasError` (carrying `status`, the problem-kind `type`
URI, `title`, `details`, and `requestUrl`). When the server sends a
`problem+json` `type` (the spec's Error Type Registry), `mapError()` dispatches
on that kind first and falls back to the HTTP status -- so, for example, a 409
`id-conflict` from `createSpace({ id })` is catchable as a `ConflictError`, and
a 507 `quota-exceeded` (a client-actionable storage-full condition, not a server
fault) as a `QuotaExceededError`. `delete()` additionally treats a 404 as
success, so it is idempotent.

Spec endpoints a given server has not yet implemented surface as
`NotImplementedError` (the server's 501).

## Contribute

PRs accepted. See [CONTRIBUTING.md](CONTRIBUTING.md) for editor setup (Prettier,
ESLint, and EditorConfig) and how it maps to CI.

## License

[MIT License](LICENSE.md) © 2026 Interop Alliance.
