@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:
| 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.
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?
| 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. 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 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.
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.