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-client

v0.98.0

Published

A developer-friendly client for Wallet Attached Storage (WAS) servers.

Readme

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

Node.js CI NPM Version

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

The WAS protocol exposes a general purpose database-like container model -- SpacesRepository > Space > Collection > Resource -- over HTTP, authorized with Authorization Capabilities (zcaps).

@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 -- editor setup and code style conventions
  • 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:

import { ZcapClient } from '@interop/ezcap'
import * as didKey from '@interop/did-method-key'
import { EddsaJcs2022 } from '@interop/ed25519-signature/eddsa-jcs-2022'
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: EddsaJcs2022
})

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

If you already have a single signer, WasClient.fromSigner() builds the ZcapClient internally (using the eddsa-jcs-2022 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:

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/spaces/',
  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.

serverUrl is the server's Spaces Repository URL (https://host/spaces/, or https://host/was/spaces/ on a sub-path mount). The reserved spaces segment sits at the server root, so the client derives the base URL for Space paths and zcap invocationTargets as its parent, exposed as was.baseUrl. A value that is not an absolute URL ending in a spaces segment is refused with ValidationError. The ./paths entry exports spacesUrl({ baseUrl }) and baseUrlOfSpacesUrl(url) for a consumer that holds one form and needs the other (a Collection URL from a capability, say, goes through parseAbsoluteSpaceTarget and then spacesUrl).

Deriving a client from a secret or seed (./identity)

Apps with user accounts usually do not hold a did:key signer directly -- they hold a passphrase, or a stored secret, and need to derive the same client from it every time. The ./identity subpath does that derivation:

import { agentsFromSecret } from '@interop/was-client/identity'
import { WasClient } from '@interop/was-client'

const agents = await agentsFromSecret({ secret: 'the account secret' })

const was = WasClient.fromSigner({
  serverUrl: 'https://was.example/spaces/',
  signer: agents.keyAgent.getSigner()
})

agents.zcapClient is the same client fromSigner builds, for callers that need it directly.

The derivation is type-sensitive, so the secret is always a string: a passphrase, or the base64url text of random bytes, not the decoded bytes. To derive from an already-derived 32-byte seed instead, use agentsFromSeed({ seed }).

The derivation is deterministic and permanent: the same secret, on any client, derives the same did:key and joins the same account. agents.keyAgreementKey and agents.keyResolver are the key-agreement key and resolver the ./edv cipher takes, so a caller building an encrypted collection has both halves from one call. The key is a ProfileKeyAgreementKey, which also carries its publicKeyMultibase, the value a caller publishes as the key's verification method.

The delegation-proof suite

Delegation proofs are signed with eddsa-jcs-2022. That cryptosuite canonicalizes with JCS (RFC 8785), which is plain JSON, so delegating runs no JSON-LD canonicalization and needs no document loader to serve the suite's context at signing time. Invocations are unaffected -- those ride HTTP signatures, not proofs.

zcapClientForSigner({ signer }), exported from the package root, is the one ZcapClient construction site that hard-codes this suite; fromSigner() above builds its client through it, and so does every ./identity derivation.

The server has to verify eddsa-jcs-2022 for grants from this client to be accepted. A server that verifies both it and the older Ed25519Signature2020 accepts chains whose links mix the two, which is what a fleet upgrading at its own pace produces.

Service discovery

Before its first signed request, the client reads the server's service description. It sends an unsigned HEAD to serverUrl, the Spaces Repository URL, and follows the Link: <...>; rel="service" header on the response, whatever its status. The WAS server itself answers that URL. The server root is often a landing page served by something else, with no service link. Then it reads the linked document with an unsigned GET. The spec fixes no path for the document, so the client assumes none. A server that advertises its Spaces Repository at a URL other than serverUrl is refused with IncompatibleServerError, since every Space path would otherwise be addressed at the wrong mount.

From the document the client selects the WAS specification version it speaks. This client speaks only v0.5. It ignores specs keys other than https://w3id.org/pws and entries without a version. If no entry names v0.5, or the document is malformed, every signed request rejects with IncompatibleServerError before anything is signed. A server whose responses carry no service link predates v0.5 and is refused the same way. The client does not fall back to the v0.4 layout.

Discovery runs once per client and is shared by every handle. A failure is not memoized, so the next request retries. Unsigned public reads (publicRead, publicListCollection, ...) take absolute URLs that may point at another server, and do not wait on discovery.

was.service() returns what discovery found:

const info = await was.service()
info.version // '0.5'
info.entry // the chosen `PwsVersionEntry`
info.description // the whole `ServiceDescription`
info.spacesUrl // the Spaces Repository URL, or undefined
info.features // e.g. ['listing', 'collection-management', 'changes-query', ...]
info.hasFeature('quotas') // false when the token is absent

// Discover again, e.g. before relying on an affordance a host may have dropped.
await was.service({ refresh: true })

features is an open token list. A token this client does not know is carried along and otherwise ignored, and an absent token means unsupported. The client gates no behavior on the document's instance member.

A caller that already holds the document (from a cache, say) can pass it as the serviceDescription constructor option. The client then selects a version from that copy without fetching, and service({ refresh: true }) discovers afresh.

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.

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. createSpace() and listSpaces() address the Spaces Repository URL the service description names (see Service discovery). Against a server whose description has no spaces URL, they throw NotSupportedError without sending anything.

const space = await was.createSpace({ name: 'Home' }) // POST {spacesUrl}
// A typed Space, e.g. an auxiliary one; `type` defaults to ['Space'].
const aux = await was.createSpace({
  name: 'Clients',
  type: ['AuxiliarySpace', 'Space']
})

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

// Read the Space Metadata object (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 Metadata object. The
// write is pinned to that read's ETag and re-merges on a lost race. It
// answers with the merged description and the write's own ETag, so a
// follow-on compare-and-swap needs no re-read.
await space.configure({ name: 'Home (renamed)' })
// { description: { id, type: ['Space'], name, controller }, etag? }

// Lost-update-safe writes: read the Metadata object with its ETag, then
// write it under `ifMatch` (412 `PreconditionFailedError` if it changed), or
// create-if-absent under `ifNoneMatch` (412 if the Space already exists).
// Nothing is read or merged on the client, so `controller` is required.
const read = await space.describeWithEtag() // { description, etag? } | null
await space.replaceDescription(
  { name: 'Home', controller: read!.description.controller },
  { ifMatch: read!.etag }
) // { etag? }
await was
  .space('fresh-id')
  .replaceDescription(
    { name: 'Fresh', controller: 'did:key:z6Mk...' },
    { ifNoneMatch: true }
  ) // { description, etag? }: a create echoes the Metadata object

await space.delete() // idempotent

// Need to know whether the DELETE actually removed anything, rather than
// treating a 404 as success? `deleteWithOutcome()` reports
// `{ outcome: 'deleted' | 'not-found' }` -- 'not-found' means absent or
// refused, since the server answers 404 for both.
const { outcome } = await space.deleteWithOutcome()

List the spaces in the repository visible to your signer with was.listSpaces(). It returns a { url, totalItems, items } listing holding every space whose controller your invocation is authorized for, auxiliary spaces included; an unauthorized caller gets an empty list rather than an error. Each item carries the space's type array, the same value as its Space Metadata object's type, so an auxiliary space (one whose type names AuxiliarySpace) can be told apart without reading it. To enumerate what is inside a space, use space.collections() (below).

const { totalItems, items } = await was.listSpaces()
// items: [{ id, type, url, name? }, ...]
const userSpaces = items.filter(item => !item.type.includes('AuxiliarySpace'))

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

// 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 Metadata object (null if missing or not visible).
const desc = await collection.describe() // { id, type: ['Collection'], name } | null

// Update (upsert; merges over the current Metadata object). Answers with the
// merged description and the write's own ETag, like `space.configure()`.
await collection.configure({ name: 'Credentials' }) // { description, etag? }

// 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 Collection Metadata object's top-level name is server-visible plaintext. An encrypted collection leaves it unpopulated by convention and carries its name and tags on the same object's custom member instead, where they are encrypted.

An application that provisions a collection can record who it was provisioned for, with the optional generator object: the application's DID (id, required), the Web origin that DID was bound to (origin), and the application's canonical URL (url, which needs origin and must share it), and its display label (name). It is accepted at create time and stays writable afterwards, so an existing collection can be backfilled. A write carrying generator replaces the stored object whole. It is a controller assertion the server persists but does not verify.

await space.createCollection({
  name: 'App Notes',
  generator: {
    id: 'did:key:z6Mk...',
    origin: 'https://app.example',
    url: 'https://app.example/notes',
    name: 'Notes'
  }
})

// Backfill an existing collection (other fields merge forward unchanged).
await space.collection('notes').configure({
  generator: { id: 'did:key:z6Mk...', origin: '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):

// 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. A ReadableStream<Uint8Array> is accepted only by an encrypted collection (see Encrypted collections). A plaintext collection refuses it with ValidationError. A plaintext resource has no chunked representation, and a signed request carries a Digest of its whole body. Buffer the stream into a Blob there.

// 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. getWithEtag() pairs the decoded value with its ETag validator, and getWithEtag({ as: 'text' }) projects it to text (decrypting where getText() would not). getStream() resolves { stream, contentType?, etag? }, or null on a miss. It runs the codec like get(), so it decrypts on an encrypted collection. A JSON body is re-serialized, so it is semantically identical JSON but not guaranteed byte-identical.

A write value is a JSON object/array or binary (Blob/Uint8Array, or a ReadableStream<Uint8Array> on an encrypted collection) -- 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:

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().

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

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 NotFoundError, the same mask as any other denied revocation.
  • 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 AlreadyRevokedError, a ValidationError subclass named for the server's capability-already-revoked problem type. A tampered or expired capability stays a plain ValidationError, and the client swallows none of them. Catch AlreadyRevokedError 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:

// 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)

// Conditional writes: the policy carries an ETag and a write stamp.
const read = await collection.getPolicyWithEtag() // { policy, etag } | null
// read.policy also carries updatedAt / updatedAtCounter / originId
const { etag } = await collection.setPolicy(
  { type: 'PublicCanRead' },
  // 412 PreconditionFailedError if created or changed meanwhile
  read === null ? { ifNoneMatch: true } : { ifMatch: read.etag }
)
await collection.clearPolicy({ ifMatch: etag }) // returns the tombstone's { etag }

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

// 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).

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.
// The write is pinned to the /meta ETag the read returned; on a resource whose
// metadata has never been written (no ETag yet) it is a guarded create instead.
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's configuration (name, backend, encryption, generator, read by describe()) and its annotations live in one object at the Collection's reserved /meta path: server-managed properties (createdAt / updatedAt / createdBy / created) plus a user-writable custom object (name and tags), under one ETag validator. meta() is that same object with custom decoded through the codec; describe() does not resolve the codec at all, so on an encrypted collection it reports custom as the opaque envelope. A server without the endpoint surfaces its 501 as NotImplementedError.

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.
await collection.setMeta({ custom: { name: 'Vault' } }, { ifMatch: meta?.etag })
// ...or create the Collection only if it does not exist yet:
await collection.setMeta({ custom: { name: 'Vault' } }, { ifNoneMatch: true })

created is the write stamp of the write that created the Collection ({ updatedAt, updatedAtCounter, originId }), kept for the Collection's life. A Collection deleted and created again carries the stamp of the later create. No write sends it back. A Collection created before the server supported the stamp may lack it.

A failed precondition throws PreconditionFailedError (412). This /meta ETag is independent of every Resource's versions, but not of the configuration members: one validator covers the whole Collection Metadata object, so configure() and setMeta() advance the same validator.

The write is a full replacement of that one object, so an annotation write re-sends the configuration members (and any member this client does not model) as read, and configure() merges over the version it pins to. Two consequences worth knowing. ifNoneMatch: true is the only annotation write that may create: it means "create the Collection only if it does not exist", creates it with no configuration, and encodes custom with the plaintext codec, since the body it sends declares no encryption descriptor (pass a per-handle encryption override to state one). Every other annotation write requires a readable current object and throws NotFoundError when there is none -- a masked 404 cannot turn a rename into a create.

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 top-level name is left unpopulated there.

The governing history log

Beside /meta, a Collection has a /meta/log sub-resource: its governing history log (the backend's governed-history-logs feature), a JSON Lines body the server derives the Collection's encryption descriptor from. The log is not a Resource of the Collection (it is absent from listings, and the changes feed carries it as a kind: 'log' entry named by URL) and not part of the /meta object, and it carries its own ETag.

const log = await collection.getHistoryLog() // { body, etag } | null

// The guarded create declares the Collection log-governed.
await collection.putHistoryLog(genesisLine, { ifNoneMatch: true })
// An append is a compare-and-swap: the prior bytes verbatim plus one line.
await collection.putHistoryLog(log.body + nextLine, { ifMatch: log.etag })

From the create on, describe() serves encryption as the log head's state with history: { method, resource } stamped on, and a direct encryption write on the Collection Metadata object throws ConflictError. The create is refused the same way on a Collection whose Metadata object already carries a client-written descriptor. A lost race throws PreconditionFailedError (412). These are the raw transport methods; the /log subpath's resourceLogStore({ collection }) (below) drives them as the resource-log store port.

A reader that holds the descriptor but not the convention follows its history pointer through the /edv subpath's logGovernedCollectionDescriptorStore, the EncryptionDescriptorStore the recipient primitives run over. Its read() refuses a history.method other than the profile's format identifier or a history.resource other than collection.historyLogUrl before any fetch, verifies the log through @interop/vh-resource-log under the controller port and chain-head pin store you supply, and refuses a served descriptor that does not match the verified head. Pass a signer to write; without one the store is read-only.

import { logGovernedCollectionDescriptorStore } from '@interop/was-client/edv'
import { memoryResourceLogPinStore } from '@interop/vh-resource-log'

const store = logGovernedCollectionDescriptorStore({
  collection,
  resolveController, // () => Promise<ResourceLogController>, your verified view
  pinStore: memoryResourceLogPinStore(),
  logId: 'space/s1/vault/meta/log' // the pin slot for this log
})
const { descriptor } = await store.read() // verified head state, `history` kept

Conditional writes (optimistic concurrency)

Every Resource carries a strong ETag validator that changes on every write (conditional writes are a baseline WAS server requirement). 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.

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.

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

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

A BackendDescriptor says where a Collection's data lives; it advertises no optional affordances. Server-wide affordances are read from the service description instead:

const info = await was.service()
if (info.hasFeature('changes-query')) {
  // the server serves the replication change feed
}

An absent token means the server makes no claim to it, so treat it as unsupported rather than assuming a default. (Client-side encryption is not a server affordance -- see below.)

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

const descriptor = await space.registerBackend({
  id: 'gdrive-personal', // unique within the Space
  name: 'My Google Drive',
  provider: 'google-drive', // selects the server-side adapter
  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:

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').

Replica registration

A Space can pull from a copy of itself on another server. The Space controller registers that peer Space with this server, and this server then runs a pull loop against the peer. Replication is one-way. The peer receives nothing unless its own server holds a registration pointing back.

A registration is a directed edge. fromSpace is the peer Space's canonical trailing-slash URL, and toSpace is this Space's. role is always source. capability is the pull capability: a zcap the controller delegates to this server's DID, rooted in the peer Space, with allowedAction within GET and HEAD. collections lists the Collections to pull. Leave it out to pull every Collection.

// The DID of the server that will pull: the reference server discloses it
// as `instance.serverDid` in its service description. `instance` is an
// operator disclosure, so a deployment may leave it out; ask the operator
// for the DID then.
const { description } = await was.service()
const serverDid = description.instance?.serverDid
if (serverDid === undefined) {
  throw new Error('This server does not disclose its DID.')
}

// The pull capability: GET-only, rooted in the peer Space, delegated to that
// DID.
const capability = await peerWas.space(peerSpaceId).grant({
  to: serverDid,
  actions: ['GET']
})

const registration = await space.registerReplica({
  id: 'peer-1', // client-chosen and URL-safe
  fromSpace: `https://peer.example/space/${peerSpaceId}/`,
  toSpace: `https://was.example/space/${space.id}/`,
  capability,
  collections: [{ id: 'credentials' }], // omit to pull every Collection
  role: 'source'
}) // the stored ReplicaRegistration

const listing = await space.replicas() // { url, totalItems, items } | null
const one = await space.replica('peer-1') // ReplicaRegistration | null

const status = await space.replicaStatus('peer-1') // ReplicaStatus | null
// status.state: 'idle' | 'pulling' | 'backing-off' | 'stalled'
for (const entry of status?.collections ?? []) {
  if (entry.state === 'stalled') {
    // entry.stall: { reason, since, detail? }
  }
}

// The data already pulled under the registration stays. 'not-found' is the
// server's masked 404: absent, or the caller is not the controller.
const { outcome } = await space.deregisterReplica('peer-1') // 'deleted' | 'not-found'

replicaStatus() reports the pull loop's runtime state. The response is uncacheable and is not part of the registration record. Each Collection entry has its own state (synced, syncing, stalled, or skipped). A stalled Collection keeps its checkpoint until the cause is removed, and the other Collections continue. Its stall.reason is one of:

  • clock-bound -- a received write stamp is dated further ahead of local time than the server allows. It clears as local time catches up.
  • fork -- a history log or an immutable Collection member differs from the local one in a way no write order resolves.
  • quota-exceeded -- applying the change would exceed a local quota.
  • unsupported-backend -- the Collection names a backend this server has no registration for.
  • container-refused -- the local Space or Collection refused the write.

Every replica method is controller-only on the server, the reads included, since a registration holds the pull capability. A delegated capability gets the masked 404. replicas(), replica(), and replicaStatus() then return null, and registerReplica() throws NotFoundError. deregisterReplica() reports it as { outcome: 'not-found' } rather than swallowing it, so a delegated caller can tell that nothing was removed. The three reads reserve null for that 404. A 2xx with no JSON body is a server fault and throws WasServerError.

registerReplica() throws ValidationError (400 invalid-request-body) for a malformed body. Each entry of the error's problems carries a pointer naming the offending member. It throws ConflictError (409) for two refusals, told apart by the error's type URI:

  • replica-refused (ProblemTypes.REPLICA_REFUSED) -- the server refuses the peer. For example, the peer Space's controller or type differs from this one, the peer advertises no replication, the capability's controller is not this server, or a listed Collection's immutable members differ. The cause is in the error's details.
  • id-conflict (ProblemTypes.ID_CONFLICT) -- a registration with this id already exists.
import { ProblemTypes } from '@interop/was-client'

try {
  await space.registerReplica(registration)
} catch (err) {
  if (err instanceof ConflictError && err.type === ProblemTypes.ID_CONFLICT) {
    // already registered under this id
  } else {
    throw err
  }
}

deregisterReplica() throws ConflictError (replica-refused) when the registration is the one that maps this Space's did:webvh controller. Removing it would leave the Space with no way to follow its controller's log.

A peer advertises replication in its service description, under specs['https://w3id.org/pws/replication'] (version 0.1 on the reference server). A registration is per-server state. It is not replicated, it is not part of an export, and deleting the Space removes it. space.describe() surfaces the Space's registrations as the read-only replicas member, one { fromSpace, toSpace, role } summary per peer, with no registration id and no capability. The server ignores replicas in a write body.

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.

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 Metadata object. Any authorized reader -- including a delegated consumer that did not create the collection -- discovers it by reading that object, 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.

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 Collection Metadata
// object:
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 Collection Metadata object declares encryption (resolution reads that object 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) instead of silently falling back to plaintext.

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

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). The same write re-seals the stored custom under the newly declared descriptor, since the server validates the envelope against the incoming one; a client that cannot build that codec (no keystore, or no keys for the descriptor) sends no custom at all rather than plaintext beside the descriptor, which loses the stored name and tags. Until the collection is re-declared, 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, 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 Collection Metadata object'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(). Serving the chunk endpoints is a conformance requirement of a server that offers encrypted collections, so there is no client-side gate -- one that does not answers 501. put(id, bigBlob) takes the same route where no document is stored at id yet, so a large binary can be created at an id of your choosing (pass ifNoneMatch: true to get a 412 when the id is taken). Two limits apply. put() over an existing document is refused, since replacing its 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. A chunked write that fails partway deletes what it wrote, and a 507 surfaces as QuotaExceededError. One that could not clean up leaves a pending stub, which every read refuses; the EDV doc cipher's isPendingStub recognizes it, and delete() removes it with its chunks. Tune the threshold and the chunk size with createEdvEncryption({ maxBlobBytes, chunkSize }).
  • Streams. A ReadableStream<Uint8Array> has no known size, so add() always routes it to the chunked-stream path, however short it is. It is encrypted and written as it is read, without buffering. put(id, stream) takes the same route where no document is stored at id yet. Over an existing document it is refused with ValidationError before the stream is read. A content-addressed collection refuses a stream for the reason it refuses a large blob. A write that fails midway cancels the stream, so the source behind it is released. The content type is options.contentType, then a guess from the id's extension, then application/octet-stream. To read without buffering, call resource.getStream() or collection.getStream(id). Its contentType is the bare media type, with no parameters, on every collection. For a chunked document the stream fetches and decrypts each chunk as it is read. A missing chunk therefore errors the stream, with NotFoundError, rather than the getStream() call; a tampered one errors it with EncryptionError. get() still returns a Blob.
  • Raw reads. get() decrypts; the getText() / getBytes() escape hatches do not (they return the stored representation).

Working offline. A consumer that only decrypts bytes it already holds -- opening an archived collection, say -- has no server to reach. Import @interop/was-client/edv/core instead of @interop/was-client/edv: it is the same codec, doc ciphers, key epochs, recipient operations, blinding keys, resourceDescriptorStore, and the log-governed descriptor stores, with every transport module left out of the import graph. collectionDescriptorStore stays on ./edv, since it reads through a live Collection handle. The example below leaves spaceId unset. A cipher told which Space its collection lives in loads the transport factory for the chunked paths. A cipher with no Space still reads a chunked envelope when decrypt is given a chunkSource, a function that serves each chunk ({ docId, chunkIndex } to the parsed chunk, or undefined) from bytes you already hold. A missing chunk throws NotFoundError, and blobBytes (also on this entry) reads the resulting Blob on every platform, React Native included.

import { createEdvDocCipher } from '@interop/was-client/edv/core'

const cipher = await createEdvDocCipher({
  keyAgreementKey,
  keyResolver,
  collectionId,
  encryption // the archived collection's encryption descriptor
})
const data = await cipher.decrypt({ id, envelope })

A consumer that also wants no resource-log dependency -- @interop/wallet-backup opening an archive with no log to verify, say -- imports @interop/was-client/edv/cipher instead: the same codec, doc ciphers, key epochs, recipient operations, blinding keys, and resourceDescriptorStore, without ./edv/core's log-governed descriptor stores. Importing it evaluates no @interop/vh-resource-log or @interop/did-method-webvh module, on top of no transport module.

Online consumers keep importing @interop/was-client/edv and nothing changes for them: it re-exports everything ./edv/core does (which in turn re-exports everything ./edv/cipher does) and adds the transport (WasTransport, createEdvEncryption, descriptor acquisition and refresh).

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:

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

Then declare what is searchable and search it:

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.

Serving the blinded-index query profile is a conformance requirement of a server that offers encrypted collections; one that does not 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. A pull hands back the feed's JSON Resources and their tombstones only, with the feed's deleted renamed _deleted; the Collection's own collection-metadata and log entries, binary Resources, and any kind the client does not know are skipped, and the checkpoint still moves past them. 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 or a putMeta against one throws WasSyncNotFoundError (404) -- both catchable subtypes of the core PreconditionFailedError / NotFoundError; a query whose checkpoint the server did not issue throws WasSyncCheckpointError (400, a ValidationError subtype), and the pull restarts from the beginning. Every other failure arrives as the typed WasError subclass for its status, carrying the server's problem+json fields.
  • isSyncConflictError / isSyncNotFoundError / isSyncCheckpointError / isSyncAuthError / isUnknownEpochError / isKeyUnwrapError / isIntegrityError classify those signals by err.name. Use them rather than instanceof: the port and the DocCipher are seams your app injects, and a tree that resolves two copies of this package makes an instanceof check quietly false. Read what you need off the matched value, such as err.status on an auth error to tell a 401 from the masked 404. The last two tell a decrypt's two no-key outcomes apart: an epoch the reader's descriptor does not list (a re-read may fix it) and an epoch it lists but this reader holds no key for (real data, permanently unreadable here, never garbage). isIntegrityError matches a stored body that does not verify against the resource id it was read under. Do not apply that row.
  • SyncStatus ('idle' | 'syncing' | 'synced' | 'error') is the closed vocabulary a replication driver reports one feed's state through.
  • 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. decrypt({ id, envelope, context? }) takes the resource id the replica read the body under (the feed row's id, not the envelope's own) and throws IntegrityError when the body was written for a different id. Pass collection.codecContext() as context to read a chunked encrypted blob, which then decrypts to a Blob. 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). Build it with the collection's spaceId to let decrypt fetch a chunked blob's chunks, or pass decrypt a chunkSource to serve them from bytes you hold. 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.
  • createRefreshingEdvDocCipher(...) (from @interop/was-client/edv) is createEdvDocCipher bound to descriptor acquisition and the unknown-epoch refresh rule: it acquires the collection's descriptor through the EncryptionDescriptorSource / EncryptionDescriptorCache seams (wasDescriptorSource is the source over a WasClient; the cache is a client-local get/put your app scopes to one Space), refuses fail-closed to build without one, and on an UnknownEpochError decrypt re-reads the descriptor, swaps itself, and retries exactly once per instance. A descriptor that carries history (a log-governed collection) is refused with UnverifiedDescriptorError unless its source declares verifiesHistory: true; wasDescriptorSource does not. An epoch rotation emits no Resource entry in the change feed, so this is how a replica meets an envelope sealed under an epoch it has never seen without a refetch per resource. acquireDescriptor / acquireDescriptors are the acquisition alone (fetch, cache the success, fall back to the cache whenever the fetch yields no descriptor), and DescriptorRefreshPolicy is the once-per-collection-per-session guard for a host whose reads scan rows instead of going through a cipher.
  • 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, plaintext, or governed, optionally world-readable). Safe to re-run on every connect. A governed collection is created with no encryption member, the caller declaring governance afterwards through the history log's guarded create. An optional capability is the invocation capability every request rides, for a client holding a delegated zcap rather than the Space's root authority. It resolves { created }: true when its own guarded create made the collection, and false when the collection already stood or a rival won the create race.
import {
  createWasSyncPort,
  createPlaintextDocCipher,
  deriveSpaceId,
  ensureSpaceAndCollection,
  isSyncConflictError
} 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 (!isSyncConflictError(err)) {
    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 por