@estoc/agent-core
v0.13.0
Published
The DIDComm v2 agent behind Estoc's clients: mediation, pickup, live delivery and hand-layered packing over an .estoc vault (config, seed keystore, contacts, append-only message log) stored through a pluggable VaultBackend — OPFS in the browser, memory fo
Maintainers
Readme
@estoc/agent-core
The DIDComm v2 agent behind Estoc's clients: mediation (coordinate-mediation
3.0), pickup and live delivery (messagepickup 3.0 over HTTP and WebSocket),
routing 2.0 forwards packed by hand, layer by layer, and user-profile/1.0
introductions, all over an .estoc vault stored through a pluggable
backend.
Runs wherever didcomm-rust's WASM does: the browser (Vite), workerd, Node. The WASM itself is not loaded here — see Didcomm API.
Layers
Agent envelopes · attribution · the log · mediation · pickup · live delivery
├─ protocol/spec what the DIDComm v2 spec defines: routing (forward), trust-ping, out-of-band, from_prior — wired into Agent
├─ protocol/mediation coordinate-mediation 3.0 · messagepickup 3.0 — transport with the mediator, never logged
├─ protocol/handler the seam for application protocols; basicmessage 2.0 and user-profile 1.0 ship as handlers
├─ protocol/ resolver (did:web + did:peer), mediator input, out-of-band helpers
├─ identity/ did:peer:4 from a seed-derived identity (@estoc/keystore v3)
└─ Vault the .estoc format: config · keystore · contacts · invitations · message log · delivery log
└─ VaultBackend bytes: OpfsBackend (browser) · MemoryBackend (tests, snapshots)Three kinds of protocol
- Specification protocols (
protocol/spec.ts) are the agent's own: how an envelope is forwarded, how afrom_priorrotation is verified, how an invitation is claimed, how a ping is answered. They are not registrable — an application cannot swap them out. - Mediation and pickup (
protocol/mediation.ts) are community protocols the agent uses as its transport. That traffic runs between the agent and its mediator, not between the user and a contact, and a delivery is only an envelope around the real mail — none of it enters the log. - Application protocols (
protocol/handler.ts) are everything between the user and a contact. The agent's part is fixed and the same for every type — open, attribute, log, home to a contact,onMessage— and aProtocolHandleradds behaviour on top, looked up by type after the record is in the log:onInboundto answer or update a contact,introduceto say something before the first message to anyone.basicmessage/2.0anduser-profile/1.0are built-in handlers;AgentOptions.handlersadds more (or replaces a built-in for a type). A message of a type nobody handles is still logged, still handed toonMessage— the application decides what to make of it.
Everything between contacts is logged, whatever its type — chat, profiles, pings, a protocol nobody here speaks. Whether it shows is the application's projection of the log, not the agent's decision.
The vault format
The contract is docs/vault-format.md at
the repository root; this package is its reference implementation.
.estoc/
config.json singleton: label, identity anchor, mediation snapshot
keystore.json singleton: @estoc/keystore v3 — one sealed seed + a plaintext cache of key names
contacts/<cid>.json record: one mutable file per contact, DID history with evidence
invitations/<id>.json record: single-use invitations issued, a DID waiting for whoever answers first
messages/<uuidv7>.jsonl log: append-only; segments named by uuidv7, read in name order
deliveries/<uuidv7>.jsonl log: what became of each outbound message: sent / failed / held, per try
state/ blobs/ cache/ reserved (per-person state · attachment bytes · rebuildable, never snapshotted)Key names are derivation paths. The seed and a name give the key (
estoc/v3/<purpose>/<name>), sokeystore.json's key list is a cache and the records are the truth: every mint writes the record naming the key (config, a contact'smyDids[], an invitation) before the cache entry, and a name the cache never heard of derives all the same. No name is a counter and none is reused:mediation/<id>/…by the mediation's uuidv7,pair/<cid>/<uuidv7>,invite/<id>.DIDs in config are snapshots, recorded when minted, and checked against the seed rather than recomputed: the anchor first, at every start (
Vault.verifyAnchor— a seed that does not derive it is the wrong keystore for this vault), every other ref as it comes into use (Vault.peerIdentity). Rotating a mediator later never silently renames an identity.The mediator is not part of the identity. A vault is created from a seed and a name alone (
mediation: null);Vault.setMediatornames a mediator later and mints the DID it will know the vault by. The public DID embeds the mediator's routing DID, so choosing one is a decision about reachability, taken after the identity exists — and changing it is a rotation of every DID that named the old one, never a silent rename (see Changing mediator below).Contacts are keyed by
cid(uuidv7). Their DIDs form a history (dids[], closed withuntil, hops proven byfromPrior), and so do ours toward them (myDids[]: the keystorekeythat derives each, andregisteredAtonce the mediator accepts it).addressedAsis the DID of ours their latest envelope was sealed to — what decides whether the next message out needsfrom_prior. The file is named bycid, so a record has one home for life; the petname lives inside it, andupdatedAt, stamped on every write, is what a merge compares.The message log stores each event as
{mid, at, direction, sender?, msg}:midis the local primary key (uuidv7, assigned at append),msgthe plaintext exactly as it arrived or left,senderthe DID the envelope proved (didcomm-rust never compares it withfrom). Whose message it is gets resolved at read time through contact DID histories — the log encodes facts, not interpretations. A line that does not parse (a crash mid-append, a corrupted byte) is reported to the reader and skipped, never fatal; the next append after a cut-short line first gives the fragment its own terminator so the two never fuse. Appends are serialised perMessageLoginstance. (SegmentedLogis the shared shape; the delivery log is another instance of it.)The delivery log is where sending lives.
sendappends the record first and only then tries to deliver it; the outcome of every try is an event{mid, at, status: sent | failed | held, attempt, to?, error?}indeliveries/, never a change to the message line.foldDeliveriesturns the events into one state permid(newest wins,sentis final); an outbound record with no event is pending. What is not sent is the outbox: tried again at every start, when the mediator's socket comes back, and ahead of the next message to the same contact — in order per contact, stopping at that contact's first failure so a conversation never arrives shuffled — onagent.flush()when the application learns the network is back, and by hand throughagent.retry(mid). The wireidnever changes across tries, so a try that reached the far side unnoticed is dropped there as a duplicate. A record an import brings in undelivered isheld: not tried unasked (a backup is a move, not a sync), retried by hand only.onDeliveryreports every event.Attribution is the envelope's. Inbound mail is attributed to the DID the authcrypt layer proves, never to the plaintext
from(which anyone can type into an anonymous envelope). Anonymous mail is logged withsender: null, belongs to no contact (counterpartyOfyields null, andonMessagehands it over with no contact), cannot rename a contact, and reaches no handler — there is nobody to answer.Pairwise DIDs, rotation by
from_prior. The public DID is a business card: strangers write to it. The first message we send anyone goes out from a did:peer:4 minted for that relationship (pair/<cid>/<uuidv7>, service = the mediator's routing DID, registered with the mediator as a recipient on the first delivery from it, or at the next start). A contact who wrote to the public DID first is told about the move the DIDComm way — every message out carriesfrom_prior, a JWT the DID they know signs over the one we now use, until a reply comes back addressed to the new DID (a contact who never wrote to us is taken to know the public DID, so a first message vouches for its fresh DID with it, and their side can tie the two). Inbound, afrom_priordidcomm-rust verified moves the contact its issuer names to the new DID (old one closed, JWT kept as evidence) — provided the envelope was sealed by that new DID. Every DID we ever minted stays openable, so mail to a retired one is not lost. What pairwise hides is the link between your contacts; the mediator still sees every recipient DID under one account.Changing mediator.
Agent.setMediator(did)on a vault that has one moves it: the old mediator is asked to drop every DID it knew us by, open invitations are withdrawn (their DIDs led there), the vault records the move (Vault.setMediator: a fresh mediation id and mediator-facing key,mediation/<id>/me, and — for every contact who wrote to the public DID and was never answered — that public DID as the closed first entry of theirmyDids[], the prior a later reply will name), and the agent starts against the new one: mediate-grant, a new public DID undermediation/<id>/public, and then the invariant every start checks — every current DID toward a contact rides the current routing DID; one that does not is closed for a fresh one — so a move cut short by a crash finishes at the next start. Each contact we have introduced ourselves to is then sent a trust-ping (2.0, no response asked) from the new DID withfrom_priorattached, so they move at once instead of at our next message; the samefrom_priorrides on that message anyway. Retired keys stay in the keystore, and their DIDs stay derivable, because a retired public DID may still have to sign afrom_priorfor someone who only ever wrote to it. What no rotation can carry: a business card already handed out names the old mediator, and a stranger who only has the card cannot follow — the old mediator bounces them (a contact who knows us finds us by it: their record's DID history includes it).Invitations. The third way to meet, and the only one where nothing public changes hands:
Agent.createInvitation()mints a did:peer:4 for nobody yet (invite/<id>, registered with the mediator), records it underinvitations/<id>.json, andinvitationUrl(base, message)makes the out-of-band/2.0 URL to hand over (goal_code: connect,goalin words; the host is whatever app should open it — every Estoc client reads only_oob).Agent.acceptInvitation(urlOrOob, petname)on the other side adds the contact by the DID inside and introduces itself at once from a DID minted for them, naming the invitation aspthid. The first envelope sealed to an invitation's DID takes it: that DID becomes ours toward the sender (moved into theirmyDids[]), the invitation is markedacceptedBy, and anyone else writing to it afterwards is turned away. Neither side owes afrom_prior, because neither ever knew the other by a public DID.revokeInvitationwithdraws an open one.At rest, the vault is plaintext apart from the seed: messages and contacts are readable files. An application wanting encryption at rest wraps the backend.
One agent per vault at a time. Two agents (two tabs) on one vault would append to the same log and rewrite the same config; the package does not arbitrate that. Browsers have the Web Locks API for the job — the application's page, not this one.
Identity
One seed (keystore v3); every key is derived from it by name, and the name is the id of the thing the key belongs to:
| key | what it mints |
| ---------- | ---------------------------------------------------------------- |
| anchor | the did:key root; the identity everything hangs off (the one fixed name — the seed alone recovers it) |
| mediation/<id>/me | did:peer:4, no service — the DID the mediator knows this vault by; id is the mediation's uuidv7 (config.mediation.id), minted by setMediator — fresh on every change of mediator |
| mediation/<id>/public | did:peer:4 whose service is the mediator's routing DID — the address for strangers; minted after mediate-grant, under the same id |
| pair/<cid>/<uuidv7> | a pairwise did:peer:4 toward contact cid, same shape as public; one uuid per DID minted toward them, recorded in the contact's myDids[] |
| invite/<id> | the did:peer:4 an invitation hands out, same shape; once taken it is the key behind that contact's myDids[] entry, name unchanged |
mintPeerDid(identity, serviceUri) is deterministic: same seed, name and
service → same DID.
Usage
import { createSeedKeystore, unlockSeedKeystore } from "@estoc/keystore";
import { Agent, OpfsBackend, Vault, resolveDid } from "@estoc/agent-core";
import { FromPrior, Message } from "./didcomm-wasm.js"; // your runtime's didcomm-rust glue
const root = await navigator.storage.getDirectory();
const backend = new OpfsBackend(await root.getDirectoryHandle("vaults/alice", { create: true }));
// first run: create — an identity needs no mediator to exist
const { doc, seedKey } = await createSeedKeystore(passphrase);
const vault = await Vault.create(backend, { label: "Alice", keystore: doc, seedKey });
// later runs: open, unlock however your app keeps the seed
// const vault = await Vault.open(backend);
// const seedKey = await unlockSeedKeystore(vault.keystore, passphrase);
const agent = new Agent({
vault,
seedKey,
didcomm: { Message, FromPrior },
events: {
onStatus: (s) => console.log(s),
onMessage: (record, contact) => render(record, contact), // the log record + the contact it is homed to (or null)
onDelivery: (event, record) => mark(record, event.status), // a try at delivering one of ours ended: sent, or failed with a reason
onContact: (c) => refreshContacts(),
onInvitation: (i) => refreshInvitations(),
onLog: (line) => console.log(line),
},
});
await agent.start(); // no mediator yet → status "unmediated"; the log still reads
await agent.setMediator("did:web:mediator.estoc.dev"); // mediate, mint the public DID, go live — or, later, move to another
// later starts on this vault mediate straight away (or pass mediatorDid to Vault.create)
const records = await vault.messages.read(); // the facts; what a record looks like on screen is yours to decide
await agent.addContact(bobDid, "Bob");
await agent.sendBasicMessage(bobDid, "hello"); // = agent.send(bobDid, BASIC_MESSAGE, { content: "hello" }); logged first, then delivered — offline it waits in the outbox
await agent.retry(mid); // try one waiting message again by hand (a held one, or a failed one now)
await agent.send(bobDid, "https://example.org/poll/1.0/question", { q: "lunch?" }, { thid }); // any protocol
// an application protocol of your own: the agent logs and homes the message; you answer inside it
new Agent({ ..., handlers: [{
types: ["https://example.org/poll/1.0/question"],
async onInbound(record, contact, agent) {
await agent.reply(contact, "https://example.org/poll/1.0/vote", { choice: "rice" }, { thid: record.msg.id });
},
}] });
// or meet without a public DID changing hands: one side issues, the other accepts
const invitation = await agent.createInvitation(); // "Write to Alice"
const url = invitationUrl(location.origin, agent.invitationMessage(invitation));
// … Bob, given the URL:
await bobAgent.acceptInvitation(url, "Alice"); // adds her, introduces himselfAgent writes to the vault before it tells anyone: log line first, event
second. UIs mirror the vault; they are not the record.
Didcomm API
The agent takes { Message, FromPrior } from whichever didcomm-rust build
your runtime loads — didcomm (browser/workerd WASM, instantiated your way)
or didcomm-node. Both export the same classes. This package refuses to
know how the WASM is instantiated, because every bundler and runtime does it
differently.
didcomm is a peer dependency for its types only; install the build you
inject.
fetch, WebSocket and resolveDid are injectable too; the tests run two
agents against an in-process fake mediator that way (test/fake-mediator.ts).
What the agent does with the mediator's queue
Every pickup step is safe to repeat. An attachment is acked once it is dealt with — logged, answered, or ignored on purpose; one that will not open (a resolver hiccup, a corrupt envelope) is not acked, because the mediator's copy is the only copy, and stays queued for the next start. A drain round that acks nothing stops the loop instead of fetching the same mail again. Inbound processing runs one delivery at a time. A socket that closes is reopened after a pickup, so nothing queued during the outage waits for the next start.
Moving a vault: snapshot and import
snapshotVault(backend) is every file under .estoc/ except cache/,
byte for byte, keyed by vault-relative path — the shape a zip holds, and
not an allowlist: what another client wrote travels too.
importVault(backend, files) lays them down and merges, never
overwrites: into an empty backend it is a restore; into a vault of the
same identity (same anchor DID) the snapshot's messages become a new log
segment minus what is already here (same mid, or the same wire message
received twice), its delivery events likewise (minus tries already here;
a held is one device's own and does not travel), its contacts win by
updatedAt, its invitations are added when missing (and marked taken
when the snapshot saw the answer — only acceptedBy/acceptedAt cross
over; registeredAt is this device's own), its config stays local (mediation is a
fact about this device), its keystore's key cache is unioned by name over
this device's sealed seed, and any other path is copied when absent and
never overwritten; a vault of a different identity is refused.
Either way, an outbound message that arrives undelivered is held for a
retry by hand (held in the outcome). How the files travel — zip,
folder, paste — is the application's business.
Backends
VaultBackend is six methods over vault-relative paths: read, write
(whole-file, atomic), append, remove, list (files), dirs
(subdirectories); walk(backend, dir) builds the recursive view a
snapshot takes. MemoryBackend is the test
double and the shape a zip unpacks into; OpfsBackend wraps a
FileSystemDirectoryHandle (needs createWritable()). A Node fs backend
is a page, if a client ever wants one. test/backend-suite.ts is the
conformance suite any backend should pass.
Development
pnpm test # vitest: backends, vault format, identity, agent × fake mediator
pnpm build # tsc → dist/License
Apache-2.0
