@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:
| Method | Result |
|---|---|
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.
| Need | API |
|---|---|
| One snapshot or paginated search | query() |
| A bounded collection that stays correct as records enter or leave a filter | observe() |
| Incremental append/delete events, including replay | subscribe() |
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 cleanupThe 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:
- Paths marked
$role: truerepresent membership. The role record'srecipientis the member DID. $actionson each data path grant member or viewer capabilities. They are the authorization policy.roleGroupslists mutually exclusive roles from strongest to weakest. It letsmembers().set()change roles and lets recipients follow the best valid assignment without handling role records directly.- 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. - 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 recipientco-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 fromcontexts.list()orcontexts.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 ascausefor 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.