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: {} },
});

let snapshot = await store.initialize();
if (snapshot.phase !== 'connected') {
  snapshot = await store.connect();
}
if (snapshot.phase !== 'connected') {
  throw snapshot.error ?? new Error('Connection was not established.');
}

const notebooks = snapshot.enbox.using(NotebookProtocol);

Browser apps must also register a service worker that calls activatePolyfills() so DRL-backed data resolves. See Browser & bundlers for the required wiring.

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.

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
const found = await notebooks.records.read('notebook/page', 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.

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?

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. false means the rendered local replica is still catching up or is offline; true means the relevant replication links are caught up. Local 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 context binds the existing records API to one owner, protocol root, and context ID. Owner and member handles expose the same context.records verbs; app code does not pass from, protocolRole, grants, or root within values. Every handle also exposes its root record ID and the collision-safe protocolContextKey(ownerDid, id) as rootRecordId and key.

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.

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 title = await shared.records.read('notebook/page/title', titleRecordId);
await shared.records.create('notebook/page/change', {
  data: { body: 'Reviewed by Alice' },
});

unsubscribeInbox();
await inbox.close();

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.

Invitation discovery is a bounded newest-first query: it reads 50 raw inbox records by default and accepts limits up to 100. Malformed, duplicate, or unsolicited records consume that bound and can crowd out older valid invitations. Pagination continuation and automatic junk cleanup are not available yet. Apps that receive the owner DID, context ID, and group through another channel can bypass inbox discovery with contexts.follow({ ownerDid, id, group }); following still proves current membership before accepting the context.

The invitation inbox is an open write endpoint

A protocol that declares any encrypted type carries a reserved enboxContextInvitation path that any DID may write to — that is what lets a stranger offer you a context. Records are capped at 8 KB and are immutable, but their number is not capped, so an attacker who knows your DID and has the protocol installed on your DWN can consume storage and crowd the discovery bound indefinitely. Fresh DIDs are cheap, so per-author limits alone would not close it, and a global $recordLimit would let an attacker fill the cap and block real invitations. There is no mitigation in Enbox today.

Apps that do not need open discovery should not call contexts.invitations; distribute the owner DID, context ID, and group over a channel you control and accept with contexts.follow() instead. Apps that do need it should treat inbox growth as untrusted input: prune dismissed and malformed records, and consider rate limiting 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.

One owned-and-shared catalog

// Read once:
const all = await notebooks.contexts.list();

for (const context of all) {
  if (context.access === 'owner') {
    showOwnerControls(context.members());
  } else {
    showMemberRole(context.role);
  }
}

// Or keep the same catalog live:
const catalog = await notebooks.contexts.observe();
const renderCatalog = ({ status, contexts, error }) => {
  if (status === 'error') report(error);
  else renderNotebooks(contexts);
};
const unsubscribeCatalog = catalog.subscribe(renderCatalog);
renderCatalog(catalog.getSnapshot());
await catalog.ready();

unsubscribeCatalog();
await catalog.close();

Observe accepted member contexts with their decoded root records when a catalog needs root metadata such as a title:

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

const render = ({ contexts, error, status }) => {
  if (status === 'error') reportCatalog(error);
  for (const { context, root } of contexts) {
    if (root.status === 'loading') renderLoading(context);
    else if (root.status === 'error') reportRoot(context, root.error);
    else renderNotebook(context, root.records[0]?.value, root.current);
  }
};

const unsubscribe = catalog.subscribe(render);
render(await catalog.ready());

// When the screen closes:
unsubscribe();
await catalog.close();

Each row exposes the immutable RecordViewState of one exact root. Rows become usable independently, keep their last records when a refresh fails, and receive cross-tab local-store updates through their context-bound record views. Root views open with at most four in flight. A current, ready row with no records means the root is missing; when current is false, replication is still behind.

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 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. Record views expose current, and member contexts expose 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.

Accepted contexts survive restart in the current agent. Acceptance does not yet replicate between a user's devices. The verified endpoint and role group remain pinned until an explicit re-follow selects replacements. When a role link pauses, Enbox checks the group's roles strongest first at that endpoint and replaces or removes the catalog entry only from that authoritative result. If the endpoint is unavailable, refresh and remote mutations fail without silently removing the catalog row. Enbox replicates only the readable context paths and keeps tenant DIDs, grants, progress cursors, and encryption envelopes out of the dapp surface.

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