enbox docs
Packages

@enbox/api

The application-facing TypeScript API for typed records, live views, and encrypted shared contexts.

@enbox/api is the main package for Enbox applications. It turns a protocol definition into typed record collections and managed collaboration APIs. App code works with records, contexts, members, invitations, and views; tenant routing, role records, grants, encryption keys, delivery retries, and sync cursors stay inside Enbox.

Installation

bun add @enbox/browser

@enbox/browser re-exports this API and adds browser connection handlers and storage defaults. Non-browser applications can install @enbox/api directly.

Application bootstrap

A typical app declares its data model once, registers it in an application manifest, and lets a connection store own session and protocol readiness.

1. Define the model

This example makes each notebook/page an encrypted collaborative context. Members can edit; viewers can read. roleGroups records that policy once so owner and member call sites do not repeat role paths or precedence.

import { defineProtocol, recordCodecs } from '@enbox/browser';

const definition = {
  protocol  : 'https://example.com/protocols/notebooks',
  published : true,
  types     : {
    notebook : { dataFormats: ['application/json'] },
    page     : { dataFormats: ['application/json'], encryptionRequired: true },
    title    : { dataFormats: ['application/json'], encryptionRequired: true },
    change   : { dataFormats: ['application/json'], encryptionRequired: true },
    member   : { dataFormats: ['application/json'] },
    viewer   : { dataFormats: ['application/json'] },
  },
  structure: {
    notebook: {
      page: {
        $actions: [
          { role: 'notebook/page/member', can: ['read'] },
          { role: 'notebook/page/viewer', can: ['read'] },
        ],
        title: {
          $actions: [
            { role: 'notebook/page/member', can: ['read', 'co-update'] },
            { role: 'notebook/page/viewer', can: ['read'] },
          ],
          $recordLimit: { max: 1 },
        },
        change: {
          $squash: true,
          $actions: [
            {
              role : 'notebook/page/member',
              can  : ['create', 'read', 'update', 'delete', 'co-update', 'co-delete'],
            },
            { role: 'notebook/page/viewer', can: ['read'] },
          ],
        },
        member: {
          $actions : [{ who: 'recipient', can: ['co-delete'] }],
          $role    : true,
        },
        viewer: {
          $actions : [{ who: 'recipient', can: ['co-delete'] }],
          $role    : true,
        },
      },
    },
  },
} as const;

export const NotebookProtocol = defineProtocol(definition, {
  notebook : recordCodecs.json<{ name: string }>(),
  page     : recordCodecs.json<{ summary: string }>(),
  title    : recordCodecs.json<{ title: string }>(),
  change   : recordCodecs.json<{ body: string }>(),
  member   : recordCodecs.json<{ name: string }>(),
  viewer   : recordCodecs.json<{ name: string }>(),
}, {
  roleGroups: {
    // Strongest to weakest. Enbox uses this order for membership and follow.
    default: ['notebook/page/member', 'notebook/page/viewer'],
  },
});

The codec for each type encodes writes and decodes record.value(). Encrypted types use the managed audience-key lifecycle. Dapp code does not provision or deliver those keys directly.

Private file records

recordCodecs.fileEnvelope() keeps a safe filename and canonicalized media type inside one versioned binary payload. The record descriptor always uses application/octet-stream; declare the protocol type as encrypted to keep the embedded metadata private with the file bytes.

const maxAttachmentContentBytes = 50_000_000;
const attachmentCodec = recordCodecs.fileEnvelope({
  formatId: 'myapp1', // exactly six ASCII bytes
});

const definition = {
  protocol  : 'https://example.com/protocols/files',
  published : true,
  types     : {
    attachment: {
      dataFormats        : ['application/octet-stream'],
      encryptionRequired : true,
    },
  },
  structure: {
    attachment: {
      $size: { max: attachmentCodec.maxEncodedBytesFor(maxAttachmentContentBytes) },
    },
  },
} as const;

const FilesProtocol = defineProtocol(definition, {
  attachment: attachmentCodec,
});

maxEncodedBytesFor() reserves enough record bytes for the supplied content and the maximum metadata overhead. Because $size.max caps the whole envelope, it is not an exact content-byte limit when metadata is shorter. Omit $size.max when the protocol has no attachment-size policy. To enforce an exact local ceiling while encoding and decoding, pass maxContentBytes: maxAttachmentContentBytes to fileEnvelope(). This is also useful when decoding untrusted files because the codec materializes the content as a Blob. A remote with a lower limit can still reject a write. Treat the decoded mimeType as untrusted and allowlist renderable types before displaying file content inline.

2. Register and connect the application

import {
  BrowserConnectHandler,
  createConnectionStore,
  defineApplicationManifest,
} from '@enbox/browser';
import { NotebookProtocol } from './notebook-protocol.js';

export const application = defineApplicationManifest({
  protocols: [NotebookProtocol],
} as const);

const store = createConnectionStore({
  application,
  connectHandler: BrowserConnectHandler({ appName: 'Notebook' }),
  monitor: { autoRefresh: {} },
});

See Build a browser dapp for the canonical startup flow. It initializes the store before rendering, calls connect() directly from a user action when needed, and registers the required DWeb service worker. Once connected, select the typed API with snapshot.enbox.using(NotebookProtocol).

The manifest is the canonical list of protocols and delegated permissions. The store restores sessions, requests the required grants, installs protocols locally, registers sync, and publishes a replacement enbox when a refreshed session replaces the old one. Use store.subscribe() and getSnapshot() with React useSyncExternalStore or the equivalent primitive in another framework. Create exactly one store for each application/data-path pairing and keep it for the application lifetime. Separate stores intentionally do not coordinate lifecycle actions or snapshots, even when they target the same dataPath. Connection sync is syncing, caught-up, or error; ready is reserved for views whose local data is usable. RecordView.current separately indicates whether its replica is caught up.

snapshot.sync.remotes reports health only for the connected DID's currently advertised DWN endpoints. Call store.retryRemote(endpoint) to freshly validate routing before retrying that endpoint's quota-blocked messages.

Owner connections are local-first. Use store.connectVault() for an explicit owner/vault flow. Hosted protocol publication is opt-in through requireHostedReadiness; an offline or endpoint-less owner can otherwise use the app locally. Invitation receiving does require the protocol to have been installed on the recipient's hosted DWN, so apps should perform hosted readiness when enabling receiving and surface delivery failures for retry. Lower-level owner lifecycles call enbox.protocols.ensureReady() with publish: false when the DID has no hosted #dwn; omitting that option attempts hosted publication by default.

Typed records

enbox.using(NotebookProtocol).records is the ordinary collection API. It injects the protocol URI, path, schema, codec, and active tenant.

// Create
const notebook = await notebooks.records.create('notebook', {
  data: { name: 'Launch notes' },
});

const pageRecord = await notebooks.records.create('notebook/page', {
  data            : { summary: 'Release planning' },
  parentContextId : notebook.contextId,
});

// Read a nested record inside its exact parent context
const found = await notebooks.records.read('notebook/page', {
  within : notebook.contextId,
  filter : { recordId: pageRecord.id },
});
if (found !== undefined) {
  console.log((await found.value()).summary);
}

// Query and continue a captured selection
const first = await notebooks.records.query('notebook/page', {
  within     : notebook.contextId,
  pagination : { limit: 25 },
});
const second = await first.next();

for await (const page of first) {
  console.log(page.id);
}

// Replace a complete value
await pageRecord.update({ data: { summary: 'Ready to ship' } });

// Derive a shallow partial update from the latest value, with one conflict retry
await notebooks.records.patch('notebook/page', pageRecord.id, (current) => ({
  summary: `${current.summary}!`,
}));

// Delete by intent
await notebooks.records.delete('notebook/page', { recordId: pageRecord.id });

The main operations are:

MethodResult
create(path, request)A TypedRecord<T>
read(path, idOrRequest)TypedRecord<T> | undefined
query(path, selection?)A lazily async-iterable RecordPage with next()
count(path, selection?)The full matching count before pagination
set(path, request)Create or replace a $recordLimit.max: 1 record
patch(path, id, patch)A freshly read and updated record
delete(path, request)void when the requested delete is proven complete
observe(path, selection)A bounded materialized ExpandableRecordView
subscribe(paths, listener)An incremental change stream

record.value() decodes the application value. record.data.text(), json(), bytes(), blob(), and stream() expose the raw representation. update({ data }) replaces the full value; patch() performs a shallow merge and may invoke its side-effect-free producer twice when retrying one conflict.

A missing read returns undefined; other non-success statuses normally throw DwnResponseError. Typed records expose required contextId, protocol, and protocolPath coordinates. An unbound typed delete also treats an already-absent record or a canonical tombstone conflict as complete. Context-bound deletion is stricter: its initial authority miss requires an authorized tombstone because the ID may identify a record outside that context. An exact same-protocol missing-parent write throws RecordParentNotFoundError.

Scope every unbound nested read

Unbound nested queries and views reject a missing or malformed within. An unbound typed.records.read() rejects a malformed value but currently accepts an omitted one, which searches that protocol path across the tenant and can return a sibling from another context. Pass the exact within value on every nested read. A context.records handle supplies its shared root automatically, but a deeper entity subtree still needs the entity's exact context.

Nested entity trees

Use a returned contextId as the address of a mid-tree UI entity. For example, an album can contain photos with sibling renditions beneath each photo:

const photo = await photos.records.create('library/album/photo', {
  data,
  parentContextId: album.contextId,
});

await photos.records.create('library/album/photo/thumbnail', {
  data: thumbnail,
  parentContextId: photo.contextId,
});

const found = await photos.records.read('library/album/photo/thumbnail', {
  within : photo.contextId,
  filter : {},
});

await photos.records.delete('library/album/photo', {
  recordId : photo.id,
  within   : album.contextId,
  prune    : true,
});

Create preview and original siblings with the same parentContextId. Round-trip photo.contextId; never rebuild its composite segments. Path plus within addresses one record only when the app enforces one current child per path and context, so include recordId when multiples are allowed. prune: true removes the entity and its descendants and cleans up partially failed multi-record creates. For materialized rows, project row.record.id and row.record.contextId explicitly; never spread the live record handle.

Compact a delta history

Declare $squash: true on an append-only protocol path. Every delta and snapshot on that path must use one monotonic application clock with identical explicit dateCreated and messageTimestamp values; the squash backstop applies to both. Create an ordinary delta and a full-state snapshot like this:

import { RecordSquashBackstopError } from '@enbox/browser';

const writeDelta = (data, timestamp) => notebooks.records.create(
  'notebook/page/change',
  {
    data,
    parentContextId : pageContextId,
    dateCreated     : timestamp,
    messageTimestamp: timestamp,
  },
);

const writeSnapshot = (data, timestamp) => notebooks.records.create(
  'notebook/page/change',
  {
    data,
    parentContextId : pageContextId,
    squash          : true,
    dateCreated     : timestamp,
    messageTimestamp: timestamp,
  },
);

try {
  await writeSnapshot(compactedState, snapshotTimestamp);
} catch (error) {
  if (!(error instanceof RecordSquashBackstopError)) {
    throw error;
  }

  const floor = error.squashFloorTimestamp;
  if (floor === undefined) {
    throw error;
  }

  const authoritative = await readAuthoritativeHistory();
  const rebased = applyPendingChanges(authoritative, pendingChanges);
  scheduleSnapshotRebase({ data: rebased, squashFloorTimestamp: floor });
}

A successful squash removes older siblings and establishes a temporal floor. The returned record's immutable squash property remains true through later updates or deletion and is also available on anonymous read-only records. A rebased write must use dateCreated and messageTimestamp values strictly newer than the reported floor. If it also races, repeat the authoritative read/rebase/write sequence with a bounded retry count. Never blindly retry the rejected snapshot.

Query, observe, or subscribe?

This choice is the same in browsers, desktop shells, CLI applications, and long-running services. The live paths use WebSocket transports, and durable feed reconciliation repairs missed notifications after a disconnect. Do not put a one-shot query() on a timer to keep primary application state current.

NeedAPI
One snapshot or paginated searchquery()
A bounded collection that stays correct as records enter or leave a filterobserve()
Incremental append/delete events, including replaysubscribe()

observe() installs its wake subscription before the first query and rebuilds the complete bounded result after matching changes. Event payloads are hints; the canonical query remains collection truth.

const viewLifetime = new AbortController();
const view = await notebooks.records.observe('notebook/page', {
  within     : notebook.contextId,
  pagination : { limit: 25 },
  signal     : viewLifetime.signal,
});

const renderView = (next) => {
  if (next.status === 'error') report(next.error);
  else renderPages(next.records, next.current);
};
const unsubscribe = view.subscribe(renderView);
renderView(view.getSnapshot());
await view.ready();
await view.loadMore();

unsubscribe();
viewLifetime.abort();
await view.close(); // joins the caller-triggered cleanup

The original pagination limit is both the initial retained-record bound and the loadMore() step. Each expansion reruns the live selection from the beginning and replaces the retained prefix, so inserts and deletes cannot make cursor-appended pages drift from query truth.

Every observable view uses the same lifecycle:

  • status: 'loading' means the first local materialization has not completed.
  • status: 'ready' means the local result, including an empty result, can be rendered.
  • status: 'error' retains the last result and exposes the failure.
  • ready({ signal }) waits for the first locally usable result.
  • close() is async, idempotent, and stops owned subscriptions.

Pass signal to a typed record observation or subscription, or to a context, invitation, or member observation, when its lifetime belongs to one caller. Aborting it rejects an opening call or closes the opened resource without ending the typed API or bound context; close() safely joins the same cleanup.

RecordView additionally exposes current. For local replicated views, false means the rendered replica is still catching up or is offline; true means the relevant replication links are caught up. A direct remote view also becomes non-current when its subscription transport disconnects and returns to current only after reconnect rematerializes the query. Usability and remote freshness are separate, so an app can remain useful offline. Do not treat an empty result as authoritative remote absence until current is true.

For append-only histories, a context-bound subscription can replay existing records before handing off to live delivery:

const changes = await page.records.subscribe(
  'notebook/page/change',
  { initial: true },
  async (event) => {
    if (event.type === 'write') applyChange(await event.record.value());
  },
);

// When the consuming component is released:
await changes.close();

The live stream opens before replay. The handoff is at least once, so consumers must tolerate duplicate events, but writes accepted during replay are not missed. Enbox keeps cursors private, bounds the overlap buffer, and closes the stream if setup or the async listener fails.

Shared contexts

A protocol recipient rule plus recipient on create is enough when one record is addressed to one DID. Use a shared context when a role should govern a related record hierarchy: a document and its changes, a project and its tasks, or a page and its attachments.

The data is not copied into every member's tenant. One DID owns the authoritative context in its DWN; accepted members replicate only that context and send authorized mutations back to the owner. Owner and member handles expose the same context.records verbs, with the tenant, role, and root scope already bound. The root record's exact contextId is the share boundary for its descendants; round-trip it rather than reconstructing it.

The NotebookProtocol above contains the complete access policy:

  1. Paths marked $role: true represent membership. The role record's recipient is the member DID.
  2. $actions on each data path grant member or viewer capabilities. They are the authorization policy.
  3. roleGroups lists mutually exclusive roles from strongest to weakest. It lets members().set() change roles and lets recipients follow the best valid assignment without handling role records directly.
  4. The owner establishes membership, then sends an invitation. The invitation is discovery only; accept() independently proves the owner, role, context, and audience key before following anything.
  5. Record and membership views stay live through the normal observe/sync path.

Cross-identity encrypted sharing requires published: true on the protocol so other identities can discover its definition and key-agreement paths. This does not publish any application record. A context is identified by both owner DID and context ID; use its collision-safe key for UI state. Each handle also exposes ownerDid, id, rootRecordId, and access.

Owner workflow

const page = await notebooks.contexts.open(
  'notebook/page',
  pageRecord.contextId,
);

await page.records.set('notebook/page/title', {
  data: { title: 'Launch plan' },
});
await page.records.create('notebook/page/change', {
  data: { body: 'First draft' },
});

const members = page.members();
let alice = await members.set(aliceDid, {
  role : 'notebook/page/member',
  data : { name: 'Alice' },
});

if (alice.delivery.state === 'awaiting-recipient-install') {
  showInstallPrompt(aliceDid);
  // After the recipient reports that installation completed:
  alice = await members.retryDelivery(aliceDid) ?? alice;
}

if (alice.delivery.state === 'delivered') {
  await page.invite(aliceDid, {
    preview: { title: 'Launch plan' },
  });
}

members().set() creates or replaces the preferred assignment, provisions the audience key, attempts key delivery, and returns queryable delivery state. get(), list(), observe(), remove(), and retryDelivery() use DIDs and typed role data; role-record IDs and duplicate cleanup remain internal. Repeat set() for each participant. Calling it for the same DID with another role changes that member's access. Use members().observe() to drive an access list instead of polling it.

delivery.state === 'delivered' means the recipient DWN accepted the key delivery write; it is not a receipt proving that the recipient decrypted it.

Removing membership prevents future authorization but does not erase plaintext already learned by a former member. Forward-secure removal still requires audience-key rotation.

Recipient workflow

const inbox = await notebooks.contexts.invitations.observe();
const renderInbox = (state) => {
  if (state.status === 'error') report(state.error);
  else showInvitations(state.records, state.current);
};
const unsubscribeInbox = inbox.subscribe(renderInbox);
renderInbox(inbox.getSnapshot());

const [invitation] = (await inbox.ready()).records;
if (invitation === undefined) throw new Error('Invitation not found.');

// accept() verifies current membership and begins exact-context replication.
const shared = await invitation.accept();

// Pull the accepted context to the current remote feed head before
// completeness-sensitive work.
await shared.refresh();

const changes = await shared.records.observe('notebook/page/change', {
  materialize : true,
  pagination  : { limit: 100 },
});
const unsubscribeChanges = changes.subscribe(renderChanges);
renderChanges(changes.getSnapshot());

if (shared.role === 'notebook/page/member') {
  await shared.records.create('notebook/page/change', {
    data: { body: 'Reviewed by Alice' },
  });
}

unsubscribeInbox();
unsubscribeChanges();
await inbox.close();
await changes.close();

Use shared.role to choose UI affordances, while relying on the owner's DWN to enforce the protocol. A viewer mutation still fails even if a client exposes an edit control.

Invitation preview fields are untrusted display text. Acceptance does not trust the invitation as membership proof: it resolves the owner-hosted role record, protocol, context root, and audience key before admitting the context. A failed accept remains retryable. dismiss() removes only the inbox item.

Apps that already receive the owner DID, context ID, and group through another channel can bypass inbox discovery with contexts.follow({ ownerDid, id, group }). Following performs the same current membership proof.

The invitation inbox is an open write endpoint

Any DID may submit a bounded invitation so strangers can offer a context. Treat previews and inbox volume as untrusted. The newest-first discovery query is bounded, so junk can crowd out older offers. Prefer an authenticated product channel plus contexts.follow() when open discovery is unnecessary; otherwise prune dismissed and malformed records and rate-limit at the hosted DWN.

refresh() performs one scoped pull from the endpoint accepted by follow() and succeeds only when that feed reaches its current head while the acceptance remains active. It throws ContextNotReadyError when membership, decryption, registration, connectivity, or replication is not ready. Once records have replicated, member reads, queries, and views remain available offline. Member mutations are sent to the context owner's DWN and therefore require it to be reachable; Enbox does not queue those mutations offline.

The followed-source catalog survives restarts only in the same agent storage. A fresh browser, device, or cleared profile starts with no accepted follows, and the accepted invitation may already be gone. For cross-device “shared with me,” add an encrypted root record such as shareBookmark to the application protocol and store { ownerDid, contextId, group } after acceptance. On startup, observe those bookmarks and call contexts.follow({ ownerDid, id: contextId, group }) for any missing catalog entry. Treat the owner's role record as authority: keep a bookmark after a transient follow failure, while a confirmed revocation should retire it.

One owned-and-shared catalog

const catalog = await notebooks.contexts.observe();
const renderCatalog = ({ status, contexts, error }) => {
  if (status === 'error') report(error);
  else renderNotebooks(contexts, (context) => ({
    key  : context.key,
    role : context.access === 'member' ? context.role : 'owner',
  }));
};
const unsubscribeCatalog = catalog.subscribe(renderCatalog);
renderCatalog(catalog.getSnapshot());
await catalog.ready();

unsubscribeCatalog();
await catalog.close();

contexts.list() returns the same owner-and-member catalog once. When a member list also needs decoded root metadata such as a title, ask the live catalog to materialize each root:

const catalog = await notebooks.contexts.observe({
  access: 'member',
  materializeRoot: true,
  signal,
});

Each { context, root } row has the root's immutable RecordViewState. Check its status and current exactly as for any other observed collection.

By default, contexts.list() and contexts.observe() include both declared collaboration roots owned by the connected identity and accepted member contexts. If the same (ownerDid, contextId) appears through both routes, owner access wins. Owned roots are discovered from local records at roots named by roleGroups; accepted member contexts come from the durable, agent-local followed-source catalog. contexts.open() remains available for binding any non-role context path, but only declared collaboration roots are automatically enumerated.

The catalog's ready state means its first local materialization completed. Use record-view current or member-context refresh() when an operation needs replication freshness.

Membership lifecycle and errors

  • memberContext.leave() withdraws the exact role record at the accepted endpoint and then retires the local acceptance. The role must authorize recipient co-delete.
  • memberContext.forget() removes only the local acceptance and does not change owner-hosted membership.
  • A replacement role proven at the accepted endpoint updates the catalog and fences retained handles with ContextRetiredError; reload the context from contexts.list() or contexts.observe().
  • A temporarily incomplete establishment or refresh throws ContextNotReadyError; retry after connectivity, installation, or sync state changes.
  • Other follow() failures use a sanitized message but preserve the original error as cause for diagnostics.

A failed refresh or remote mutation does not silently remove the existing catalog row.

For wallet-delegated member sessions, a foreign owner's DWN can enforce the embedded grant's expiry but does not receive revocations stored only on the member's own DWNs. Treat revocation of a compromised delegate as expiry-bounded until foreign-context revocation is made verifier-visible.

Advanced entry points

Most applications should use a connection store. Lower-level entry points remain available when another layer owns lifecycle:

import { Enbox } from '@enbox/api';
import { AuthManager } from '@enbox/auth';

const auth = await AuthManager.create({
  password,
  dwnEndpoints: ['https://dwn.example'],
});
const session = await auth.restoreSession()
  ?? await auth.connectVault({ createIdentity: true });
const enbox = Enbox.fromSession(session);

// ...use enbox...

enbox.close();
await auth.disconnect();
await auth.shutdown();

For published, unsigned reads without an identity:

const { dwn } = Enbox.anonymous();
const { records } = await dwn.records.query({
  from   : authorDid,
  filter : {
    protocol     : 'https://example.com/public-blog',
    protocolPath : 'post',
  },
});

Raw DWN methods, explicit foreign-tenant routing, protocol-role invocation, and exact response envelopes are advanced escape hatches. Managed shared contexts should use contexts, members, and context.records instead of hand-writing role records or key delivery.

Cleanup

Close views and subscriptions when their UI owner is released. When the user signs out, disconnect and dispose the connection store:

await store.disconnect();
await store.dispose();

After disconnect, use the replacement enbox from a later connected snapshot; do not retain APIs or context handles from the ended session.

On this page