@layr8/broker-client
v0.2.0
Published
A Layr8Client with @layr8/sdk's shape that speaks through the local layr8 broker daemon, on a temporary child DID or on this agent's persistent identity
Readme
Broker local client
A Layr8Client with the shape of @layr8/sdk's, for a program that wants to
use the local layr8 broker daemon's node connection instead of opening its own.
The design is ADR 0017,
section 2. The wire is
docs/specs/local-client-protocol.md.
Published as @layr8/broker-client. This version, 0.2.0, is a stable
release on the npm latest dist-tag: npm i @layr8/broker-client resolves
it. Prereleases go to next and have to be pinned; this one does not.
The daemon line does not follow this number. @layr8/mcp is 0.5.0. The
two lines are versioned separately and always have been — this one reached
0.2.0 through two prereleases and the daemon line reached 0.5.0 through one,
so the suffixes they passed through never matched.
Install the layr8-broker 0.5.0 line, the daemon this release is
published beside. It is the first that offers this agent's persistent
identity (identity.fixed) and the read of that identity's credential store
(credentials.fixed). An older daemon refuses both at hello, by capability
name, before anything is joined.
A default install resolves it:
npm i -g @layr8/mcp
# or a binary:
curl -fsSL https://raw.githubusercontent.com/layr8/broker/main/install.sh | shA binary already installed on the default latest channel updates itself to
0.5.0 at its next check; nothing has to be asked for by name.
The oldest daemon that serves this client's protocol at all is 0.4.0-rc.1
(MIN_BROKER_VERSION), and that has not changed. One older than that fails
connect with BrokerProtocolError, naming the version. A layr8-broker
0.4.0 daemon does serve a BORROWED session of this release — it answers the
join without saying which identity it served, and client.identity reads
unstated rather than guessing.
MIN_BROKER_VERSION is a word in those two messages and is never compared
to anything: what gates is hello, the protocol version and the capability
list, which are what a daemon answers about itself. A version number would
have discriminated nothing for the defect that made this rule — both daemons
reported 0.4.0 — and would be wrong here, because 0.4.0 works for a borrowed
session.
A daemon that is already running is used as it is, so stop an older one first. Avoid 0.4.0-rc.1: that binary replaces itself with 0.3.0, which cannot serve this client.
Use
// the only line that changes
import { Layr8Client, logErrors, randomChildSegment } from '@layr8/broker-client'Every name @layr8/sdk exports is exported here unchanged, except
Layr8Client and mediation.
There is one path and no switch. Layr8Client is always backed by the
broker daemon for LAYR8_MCP_ENV (default dev), and there is no fallback: a
program that wants its own node connection uses @layr8/sdk directly.
| LAYR8_VIA_BROKER | What happens |
| --- | --- |
| unset or "" | nothing; it is not an ask |
| 1 | nothing; it asks for what happens anyway |
| 0 | constructing a client throws, naming the variable. It used to select the direct path, which is gone, and silently giving it the broker would put the session somewhere nobody chose |
| anything else | constructing a client throws |
A configuration written for the old mode
LAYR8_NODE_URL and LAYR8_API_KEY configured a launcher's own sign-in to a
node. Nothing signs in with them here — the broker signs in with its own
enrolment — so constructing a client fails, naming whichever of the two is
set and giving the two commands that replace them:
LAYR8_NODE_URL and LAYR8_API_KEY are set, and nothing signs in to a node with them any more.
A session now goes through the layr8 broker on this machine, and the broker signs in with its
own enrolment. Two steps replace them:
1. layr8-broker enrol --name <label> --node <wss-url> --api-key <key>
The node and the key are the two values above, so they move across as they are.
2. Point your launcher at the profile <label>. Each launcher asks for it its own way, so
the command is in that launcher's own README.
Then remove LAYR8_NODE_URL and LAYR8_API_KEY from the launcher's environment.Step two names no command on purpose. The launchers do not share an installer — some register a profile at install time, some take one per session — and this package cannot run or check any of those spellings, so a command printed here would read as something somebody verified. If you maintain a launcher and want the refusal to name yours, that spelling has to come from your repository.
Either of them set to nothing is not an ask and passes: a blank line in a
.env configures no sign-in. LAYR8_AGENT_DID is not refused — it still
names the DID a session asks for. It is the environment that is refused and
not nodeUrl / apiKey in the config object, which is a launcher's own source
and not what those two commands replace.
If no daemon listens on ~/.layr8/broker-<env>.sock, the client starts one:
$LAYR8_BROKER_BIN daemon, or layr8-broker daemon from PATH when
LAYR8_BROKER_BIN is unset. If that program cannot be run, or starts and does
not listen within 10 seconds, connect rejects with BrokerUnavailableError
(a ConnectionError) and the daemon's log.
Which identity a session gets
It is a field, never the shape of the config.
// a temporary child of the enrolled agent DID — the default
new Layr8Client(onError, { didSpec: { parentDid: agentDid, mode: 'Create' } })
// this agent's persistent identity: the enrolled agent DID itself
new Layr8Client(onError, { identity: 'fixed', agentDid })| identity | What is joined |
| --- | --- |
| absent or 'borrowed' | a child DID named beneath didSpec.parentDid. Name the segment with agentDid, or leave it empty and the SDK's rule generates one here |
| 'fixed' | this agent's persistent identity: the enrolled agent DID, bound by the daemon and lent to one session at a time. did after connect is that DID |
client.identity is what the daemon SAID it served, read from the join
result. It has three values and an absence, and no two of them mean the same
thing:
| client.identity | What it means | What to do about it |
| --- | --- | --- |
| undefined | no join has answered | connect |
| 'unstated' | a join answered and the daemon did not say — a layr8-broker older than the field serves the same protocol version and omits it. layr8-broker 0.4.0 is exactly that daemon, it is what is installed wherever nothing has been upgraded, and this client starts one from PATH when none is listening | upgrade the daemon to the 0.5.0 line; connecting again changes nothing |
| 'borrowed' / 'fixed' | the daemon said so | nothing |
This client never fills the value in from what it asked for, and never reads
'unstated' as undefined: telling a connected session to connect is an
instruction that cannot help.
mediation.* refuses with a different sentence for each of the first three.
An agentDid with no didSpec.parentDid is not an ask for either. Earlier
versions of this package turned that shape into a borrowed child of that DID —
a temporary identity, under a configuration that looks like it names a stable
one. It does not any more: the join goes out as configured, the daemon refuses
it no-parent, and the refusal names the two asks that exist.
A fixed join is refused, before anything is sent, when it asks for something
the daemon's own binding cannot do. Each refusal arrives as a
BrokerJoinRefusedError — a ConnectionError, so an existing catch is
unchanged — whose refusal is the reason:
| refusal | What it means |
| --- | --- |
| fixed-identity-not-configured | this profile offers no persistent identity. layr8-broker enrol --persistent-identity |
| fixed-identity-not-bound | it is configured and the binding did not succeed. bindingState says which of the causes it was |
| fixed-identity-held | another session has it; holder and since name who and when |
| fixed-identity-mismatch | the join also named a didSpec.parentDid, or an agentDid that is not the identity |
| fixed-identity-type-undeliverable | a handle(type, …) for a type the daemon's binding cannot deliver |
| fixed-identity-protocol-unbound | a handler type whose protocol that binding did not bind |
The last two exist because the identity's join is the daemon's, made once before any session, and never remade: a handler or a protocol added from a session would never fire while every step reported success.
Read the refusal's fields, not its sentence. Beside refusal, a
BrokerJoinRefusedError carries fixedDid, holder, since and
bindingState — everything the daemon put on the refusal, each as a field, so
a launcher that draws held by X since Y never matches a regular expression
against message. The sentence is free to be reworded; a field is not.
Each of the four has three readings, never two:
| Reading | The field | stated |
| --- | --- | --- |
| absent — the daemon put nothing here | undefined | does not contain the name |
| said — the daemon's own string, verbatim, "" included | that string | contains the name |
| unreadable — the daemon sent something that is not a string | undefined | contains the name |
An absent field is never the calm answer. An absent fixedDid means this
daemon cannot name the identity's DID, not that the DID does not matter; an
empty holder is an anomaly — the field is documented never empty — and not
"nobody holds it". refusalData is the payload as it crossed the socket.
What works through the daemon
The members a launcher uses, with the SDK's arguments and return shapes:
new Layr8Client(onError, cfg), handle before connect, connect, close,
did, send, request (thread correlation, parentThread, signal),
on('disconnect' | 'reconnect'), delegatedCredentials and
supportsEphemeralDelegation.
The borrowed DID must be named beneath the daemon's enrolled agent DID:
didSpec.parentDidis that DID, andagentDidis it plus one segment you choose (for examplerandomChildSegment()). Left empty, the SDK's rule generates the segment in this process. WhenparentDidis not the daemon's enrolled agent DID,connectrejects with aConnectionErrorwhoserefusalisparent-not-enrolled.mediation.*runs only for a session holding the persistent identity. That DID's join binds both mediation protocols, so the steps that go over the wire work. The steps that read the node's REST store or post to/didcomm(declare,undeclare,reinject, andcollect/pickupthrough them) do not: the daemon holds this agent's enrolment. They fail with a sentence saying so, in the shape each step reports anything else. The daemon arms the Space's mediator for this identity on every claim by itself —layr8-broker doctorreports where that stands — so a launcher usually has nothing to call here. A client on a borrowed child is refused at the call, and one that cannot yet tell which identity it is on is refused with a different sentence again: "which identity" is not a question this client answers before the daemon has.These two refusal shapes are not the same, and a caller has to handle both. The steps' own failures are values —
{ ok: false, error }. This package's identity check is a synchronousthrowofBrokerCapabilityError, because it sits in the wrapper in front of the step rather than inside it. Soconst r = await mediation.status(c, m); if (!r.ok)does NOT catch a borrowed session: it throws before it returns. Wrap the call, or checkclient.identity === 'fixed'first.nodeUrlandapiKeyare not used. The daemon connects with its enrolment.Grants are attached by the SDK in the daemon, from the credentials the node delegated to your DID at the join.
handle(type, fn)bindstype's protocol at the join, so replies of that protocol reachrequest, and runsfnfor messages nobody is waiting for. A reply reaches your DID only on a protocol you bound, exactly as with the SDK directly.disconnectandreconnectare raised when the daemon's node connection drops and comes back, and when the socket to the daemon does. After a socket loss the client dials again with the SDK's backoff, starting the daemon if needed, and joins the same DID before it raisesreconnect.
What does not, and fails with the capability's name
| You call | Fails | Capability |
| --- | --- | --- |
| handleAll | connect | handlers.catch-all |
| handle(..., {manualAck: true}) | connect | handlers.manual-ack |
| a mediator (or LAYR8_MEDIATOR_DID) | connect | mediation |
| mediation.* on a client that is not holding the persistent identity, or that cannot tell whether it is | the call, in that step's own shape — see below | mediation |
| the credential calls other than listCredentials (getCredential, storeCredential, signing, verifying, presentations) | the call | credentials |
| listCredentials on a borrowed child, or against a daemon that does not serve the read | the call | credentials, credentials.fixed |
| joinDid, leaveDid, refreshGrants, mcp | the call | the member's name |
A mediation step refused because of which identity the client holds is
reported the way that step reports anything else: enroll, declare, undeclare, pickup, live,
status and bootstrap answer { ok: false, error }, so the documented
const r = await mediation.status(c, m); if (!r.ok) sees it. reinject and
collect throw, because their results carry no failure of their own —
reinject's ok is a list of ids, so { ok: false } there would read as a
failure to if (!r.ok) and as a crash to r.ok.length. @layr8/sdk draws the
same line for the same reason.
listCredentials is answered for this agent's persistent identity: that
identity is the enrolled agent DID, it holds its own grants in the node's
credential store, and the daemon — which holds the enrolment — reads them for
the session holding it. You get the node's records, in the SDK's
StoredCredential shape.
It refuses for three different reasons, because the reader's next step differs
every time. A borrowed child has no credential store of its own: what it
can spend is delegatedCredentials(). A daemon older than this read does
not offer credentials.fixed, serves the same protocol version and says
nothing else about it, so the refusal names the upgrade. And a session whose
identity the daemon did not state is not guessed at.
An empty array means the node answered and this identity has nothing it can
present. A read that did not happen throws BrokerCredentialReadError, which
is a class and not a sentence, so the two cannot be collapsed by accident: an
identity reported as having nothing because a request failed is an agent
invented out of a failed read.
An empty array is not "this identity was granted nothing", and you must not
print that for it. The node's read leaves out a credential it knows to be
revoked, so an identity that was never granted anything and an identity whose
every grant has been revoked both come back as []. Nothing on this path can
separate them. For the same reason, a record you do get is "not known to be
revoked" rather than "proved live": that filter fails open, so a credential the
node could not judge is still returned.
timeoutMs is passed to the daemon, which makes the read with fetch, so it
is a total deadline. The SDK's ListCredentialsOptions.timeoutMs, whose
type you are reading, is a socket-INACTIVITY deadline. Same name, same default
(30s), different measurement — raise it here for a slow read, not for a slow
first byte.
Differences you can observe:
- A
requeststill waiting when the socket to the daemon drops rejects withNotConnectedError. The SDK keeps such a request waiting across a node reconnect. - The daemon does not replace a daemon of an older version, as the shim does.
A daemon too old for the protocol fails
connectwithBrokerProtocolError.
Dependencies
@layr8/sdk is a peer dependency, >=0.4.1 <0.5.0. Install it yourself;
this package uses your copy and never brings its own.
There must be exactly one copy of the SDK in your program. Its error classes,
such as NotConnectedError, are checked with instanceof, and a class from a
second copy fails that check against the first: a disconnect reads as some
other failure. This package re-exports the SDK's own classes, so an error it
raises is an instance of the class you import from either package.
npm refuses to install this package beside an SDK outside the range (for
example ^0.3.0) with ERESOLVE, instead of nesting a second copy.
The tests run against 0.4.1, the lower bound. test/single-sdk-copy.test.ts
installs the packed package into a program that uses another SDK version in the
range, and fails if a nested copy appears.
