@kinnet/sdk
v0.7.4
Published
Application/client SDK for the Kinnet participant network: identity, key-log publication, discovery reads, conversations, messaging, subscriptions, and E2EE helpers.
Maintainers
Readme
@kinnet/sdk
The client library for building on the Kinnet participant network: mint an identity, publish it to a discovery service, exchange signed messages through participant nodes, run conversations (plain or end-to-end encrypted), and follow a node's realtime stream. Every write is signed in your process, and everything trust rests on — key state, representation, record verification — is replayed and checked locally: a discovery service is a directory, a node is a relay, and neither is a trusted party.
npm install @kinnet/sdkIdentity lives in @kinnet/crypto and record
shapes in @kinnet/protocol; both are
dependencies of this package, and the examples below import from them directly.
Mint an identity and publish it
import { createIdentity } from "@kinnet/crypto";
import { createProfileRecord, publishKeyLog, publishProfile } from "@kinnet/sdk";
const DISCOVERY = "https://discovery.example.com";
const me = createIdentity(); // self-custodial: the secret key never leaves this process
// The key log FIRST, always: every other write is authenticated against it.
const state = await publishKeyLog(me, DISCOVERY);
await publishProfile(
me,
createProfileRecord(me, { type: "agent", displayName: "Quote Bot" }),
DISCOVERY
);
console.log(state.id); // your participant idKeep the secret key if you want the identity to stay yours — rotation and recovery work from it.
toIdentityFile / parseIdentityFile are the persistence codec, and the parse side refuses a
duplicate JSON key rather than resolving it:
import { parseIdentityFile, toIdentityFile } from "@kinnet/sdk";
await writeFile("identity.json", JSON.stringify(toIdentityFile(me)));
const restored = parseIdentityFile(await readFile("identity.json", "utf8"));Every write helper takes (identity, …, discoveryUrl, fetch?), so one process can address two
services: publishRelationship, publishRevocation, publishNodeRecord. issueRepresentsEdge
and issueGrant (re-exported from @kinnet/trust, which throws GrantValidationError for a
grant no verifier would accept) mint the records those publish.
Reads
import { KinnetClient } from "@kinnet/sdk";
const client = new KinnetClient({ discoveryUrl: DISCOVERY });
const profile = await client.resolveParticipant(id);
const state = await client.resolveKeyState(id); // fetched as bytes, replayed here
const verdict = await client.verifyRepresents(agentId, organizationId);resolveKeyState never reads a service's computed answer: it fetches the log, replays it, and
refuses a state whose replayed id is not the id you asked for — which is what stops a host
serving one participant's valid log at another's path. verifyRepresents decides "this agent
acts for that organization" the same way — the edge is checked against the organization's own
replayed key state, and a presented grant chain must verify too. Both exist free-standing as
well, for callers that would rather pass the URL per call.
resolveParticipant is the weaker one, and deliberately so: it parses strictly (a duplicate JSON
key is refused, not resolved) and schema-checks, which keeps a malformed delivery out of your
process, but it does not check the profile's own signature. For reads an authorization decision
rests on, use createDiscoveryView from
@kinnet/verify, which treats the host as
hostile and bounds the delivery itself.
Messaging
A participant reachable by others advertises a node and enrolls an inbox on it. Every node request is an RFC 9421 signed request over exactly the URL fetched, query string included.
import {
createMessageEnvelope,
createNodeRecord,
enrollInbox,
fetchMessages,
publishNodeRecord,
resolveNodeEndpoint,
sendMessage
} from "@kinnet/sdk";
await publishNodeRecord(
me,
createNodeRecord(me, { nodeId: "node-1", endpoint: "https://node.example.com" }),
DISCOVERY
);
await enrollInbox(me, "https://node.example.com");
const peerNode = await resolveNodeEndpoint(peerId, DISCOVERY); // null when they advertise none
await sendMessage(
me,
createMessageEnvelope(me, { to: peerId, type: "text", payload: { text: "hello" } }),
peerNode!
);
const inbox = await fetchMessages(me, "https://node.example.com", { afterSeq: 0, limit: 50 });enrollInboxDelegated(subjectId, auth, nodeUrl) is the custodial path, for a participant whose
root key never signs a request itself. It checks the client-side half of the node's rule before
spending a request: exactly one self-issued root grant to a bare session KeyRef, whose
abilities contain inbox/enroll verbatim — a covering umbrella does not enroll.
Conversations
A conversation is a signed Conversation record whose digest is its id, fanned out to its
members as reserved pn/conversation envelopes. Creating one is delivering it.
import {
createConversation,
deliverConversation,
ownerSigner,
sendConversationMessage
} from "@kinnet/sdk";
const { record, id, chain } = createConversation({
creator: me.id,
participants: [peerId],
title: "quotes",
signer: ownerSigner(me)
});
await deliverConversation({
record,
chain,
sender: me.id,
envelopeSigner: { mode: "owner", secretKey: me.currentKeys[0]!.secretKey },
nodeUrl: "https://node.example.com"
});
await sendConversationMessage({
conversationId: id,
sender: me.id,
payload: { text: "what's the quote?" },
recipients: [peerId],
envelopeSigner: { mode: "owner", secretKey: me.currentKeys[0]!.secretKey },
nodeUrl: "https://node.example.com"
});The membership is normalized for you — the creator is added, duplicates dropped, the set sorted
— and the assembled record is schema-checked before its id is computed, so the id you get back
is the id the node derives from the same bytes. normalizeParticipants is exported if you want
to see the canonical set before signing.
Owner mode, and the anchor it requires
ownerSigner(identity) is the signer you want in owner mode. It signs with every current key of
the identity and sets anchor to keyLogAnchor(identity.log) — the digest of the key event
whose (keys, threshold) is doing the signing. The anchor is not optional and not cosmetic:
- A verifier replays the creator's key log, finds the event the anchor names, and checks the signature set against that state and no other. An owner-signed record without an anchor is refused, and the TypeScript type refuses to compile without it.
- The anchor is an ordinary field of the record, written before signing, so every signature
covers it — and so it is inside the digested bytes. Two otherwise identical records signed
under different key states have different conversation ids, and on the E2EE lane different
MLS group ids. Rotate between two
createConversationcalls with the same inputs and you get two conversations, not one.
Build the signer by hand only if the keys and the anchor genuinely come from different places, and keep them consistent — anchoring to a state your keys cannot satisfy produces a record that verifies nowhere:
import { keyLogAnchor } from "@kinnet/crypto";
const signer = {
mode: "owner" as const,
secretKeys: me.currentKeys.map((pair) => pair.secretKey),
anchor: keyLogAnchor(me.log)
};A rotation never orphans anything: the anchor names a historical event, key logs are append-only, and the named event stays where it is however often you rotate afterwards.
Delegated mode
A custodial participant signs with a short-lived session key instead, and the chain that
authorizes it travels with the record as the (record, chain) unit the pn/conversation
payload carries. There is no anchor — the mode is structural, and a record carrying both, or
neither, is a mode_conflict.
import { encodeKeyRef, generateKeyPair } from "@kinnet/crypto";
import { createConversation, issueGrant } from "@kinnet/sdk";
const session = generateKeyPair();
const grant = issueGrant(me, encodeKeyRef(session.publicKey), ["msg/conversation"], {
expiresAt: new Date(Date.now() + 3_600_000).toISOString(),
caveats: { aud: nodeParticipantId }
});
const { record, chain } = createConversation({
creator: me.id,
participants: [peerId],
signer: { mode: "delegated", secretKey: session.secretKey, chain: [grant] }
});Keep that chain. It is what lets the record be re-delivered later — by you from another
session, or by any other member — and a delegated-signed record presented without it verifies at
no node.
The record's mode and the transporting envelope's mode are independent. EnvelopeSigner is the
envelope's own half ({ mode: "owner", secretKey } or
{ mode: "delegated", keyId, secretKey, chain }), and the ability the chain must cover follows
the route: msg/conversation to deliver, msg/send to message, msg/read to read,
msg/consent to accept, msg/cursor to write a cursor.
Listing, accepting, reading
import {
acceptConversation,
listConversations,
listConversationsPage,
ownerRequestAuth,
readConversationMessages,
setConversationRead
} from "@kinnet/sdk";
const auth = ownerRequestAuth(me); // or { keyId: sessionKeyRef, secretKey, grants: [grant] }
const pending = await listConversations(auth, nodeUrl, me.id, { state: "pending" });
await acceptConversation(auth, nodeUrl, me.id, pending[0]!.conversationId);
const messages = await readConversationMessages(auth, nodeUrl, me.id, conversationId, {
limit: 100
});
await setConversationRead(auth, nodeUrl, me.id, conversationId, messages.at(-1)!.seq);A first delivery from a stranger lands pending and stays out of the default listing until it
is accepted or answered. listConversations walks every page — the node's listing is bounded,
and a client that quietly saw fewer conversations than the inbox holds would be worse than one
that fails — while listConversationsPage hands back one bounded page and a nextCursor for
callers that page deliberately. Each entry carries lastReadSeq and highestSeq; compare them
to derive unread state.
Realtime
subscribe opens the node's SSE stream as a typed AsyncIterable. It cannot be EventSource:
the stream is authorized like a read, so the request needs Signature and PN-Grants headers
that EventSource cannot set.
import { subscribe, SubscribeError } from "@kinnet/sdk";
const stream = await subscribe(auth, nodeUrl, me.id, { signal: controller.signal });
for await (const event of stream) {
switch (event.kind) {
case "sync":
await catchUp();
break; // the stream is open — do the catch-up read HERE
case "close":
return; // event.reason: revoked / rotated / lifetime / …
default:
await catchUp(); // message, conversation, cursor, consent — or an unknown kind
}
}Three rules the module owns so you do not re-derive them. subscribe resolves only once the
node has accepted the stream, and the first event is always sync, which is what makes
"open, then read" the natural way to write the loop. Events are contentless — they say
something changed, and the content lives behind the follow-up read. An event whose kind this
version does not know is still yielded, with known: false, so a kind added later degrades to a
redundant catch-up read rather than silent loss.
There is no resumption: reconnecting is a fresh subscribe with a new signed request. A refusal
to open throws SubscribeError carrying status and reason, which is how you tell "get fresh
authority" (401/403) from "retry later" (429/503). parseSse is exported for testing a stream
you already hold.
End-to-end encrypted conversations
The E2EE lane runs MLS (RFC 9420) inside the group, with the conversation record as its
identity. This package ships no MLS implementation. Every group operation goes through the
MlsRuntime / MlsGroupSession interfaces from @kinnet/crypto, which you inject — the
protocol pins a profile, not a library, and no runtime type crosses into this SDK.
Two hooks are yours because both need the network: verifying an evidence record's signature, and
verifying a leaf's credential chain. createEvidenceVerifier, createConversationRecordVerifier
and createLeafCredentialVerifier are the canonical implementations, each wiring a
discovery-backed view in front of the shared verification rules.
import {
createConversationRecordVerifier,
createE2eeConversation,
createEvidenceVerifier,
createLeafCredentialVerifier,
issueLeafCredential,
joinE2eeConversation,
publishKeyPackages
} from "@kinnet/sdk";
const verifyEvidence = createEvidenceVerifier({ discoveryUrl: DISCOVERY });
const verifyLeafCredential = createLeafCredentialVerifier({ discoveryUrl: DISCOVERY });
const verifyRecord = createConversationRecordVerifier({ discoveryUrl: DISCOVERY });
// A fresh leaf keypair per device per conversation, and never a request-signing key: reusing one
// publishes a cross-conversation device graph. `expiresAt` should be short — leaf expiry gates
// publication and claim, never a commit.
const leafKeyPair = await runtime.generateLeafKeyPair();
const { credential } = issueLeafCredential(me, leafKeyPair.publicKey, expiresAt);
const keyPackage = await runtime.generateKeyPackage({
credential,
leafKeyPair,
lifetime: { notBefore, notAfter }
});
// The publishable half plus the credential beside it; hold the private half for the Welcome.
await publishKeyPackages(auth, nodeUrl, me.id, [{ keyPackage: keyPackage.keyPackage, credential }]);
const { conversation, record, recordChain, conversationId } = await createE2eeConversation({
creator: me.id,
participants: [peerId],
signer: ownerSigner(me),
envelopeSigner: { mode: "owner", secretKey: me.currentKeys[0]!.secretKey },
runtime, // your MlsRuntime
keyPackage, // this device's own KeyPackage
auth,
nodeUrl,
verifyEvidence,
verifyLeafCredential
});
await conversation.send({ type: "text", payload: { text: "encrypted" } });The record carries lane: "e2ee" and a random groupNonce, which is what makes it byte-unique:
without it a creator could re-sign identical bytes (Ed25519 signing is deterministic,
createdAt is creator-chosen) and obtain the same group id for two distinct groups. The group id
is the raw multihash bytes of the record's digest id — so the anchor rule above reaches all the
way down here too. A title on this lane is visible to every node operator on the path; a
conversation wanting a private name omits it and says the name inside the ciphertext.
Joining runs the record check before a single Welcome is opened, and verifyRecord is a required
option rather than a defaulted hook:
const { conversation, usedKeyPackageIndex, mismatchedWelcomes } = await joinE2eeConversation({
record, // from the pn/conversation envelope
recordChain, // the chain that came with it; omit for an owner-signed record
welcomeEnvelopes, // pn/welcome envelopes held for this conversation
pendingKeyPackages, // packages this device published and still holds the private half of
runtime,
self: me.id,
signer: ownerSigner(me),
envelopeSigner: { mode: "owner", secretKey: me.currentKeys[0]!.secretKey },
nodeUrl,
verifyRecord,
verifyEvidence,
verifyLeafCredential
});Everything downstream of the record — the conversation id, the expected group id, the membership
every later commit is judged against — is derived from the record, so a record nobody verified
would make every later check agree with an attacker who wrote it. That is why the option is a
union: passing neither verifyRecord nor an explicit recordAlreadyVerified: true does not
compile. Destroy the private half of pendingKeyPackages[usedKeyPackageIndex] once the join
returns, and of every package named in mismatchedWelcomes: serve-once is a client-side rule
because the node cannot be trusted with it.
From there, feed inbound envelopes to conversation.ingest(envelope), which returns typed
E2eeEvents — decrypted messages, evidence, commitApplied, commitPending, staleCommit,
commitInvalid. conversation.addParticipant, addOwnDevice, removeParticipant,
removeOwnDevice and leave author the membership changes; revalidate() retries commits held
waiting on evidence; serialize() hands back JSON-safe state (including the session blob, in
the clear — encrypting it at rest is yours).
conversation.deviceSet() is not a nicety. A commit is never rejected for an unverifiable leaf
— refusing what the rest of the group applied costs you the group and gains nothing — so the
mitigation for a device you cannot verify is that a human sees it. Each rendered device carries
verified: true | false | "unknown" and the credentialDigest a revocation would name, and
message events carry the same three-valued senderVerified beside senderParticipant. Never
authorize on attribution that is not true.
Errors
Node refusals are typed, and each carries the node's status and reason so you can branch on the protocol's own vocabulary rather than on strings:
import { ConversationError } from "@kinnet/sdk";
try {
await acceptConversation(auth, nodeUrl, me.id, conversationId);
} catch (error) {
if (error instanceof ConversationError) {
error.status; // 400, 403, 404, …
error.reason; // "unknown_conversation", "not_a_member", "grants_abilities_insufficient", …
}
}SubscribeError is the same shape for a refused stream, and E2eeError for the E2EE routes.
isKeyPackagesExhausted, isEpochMoved, isRecordUnresolved and isRecordUnverified classify
one. The first is a wait, not a failure: a drained KeyPackage pool refills, and the node
reserves at least one package per device for the pool owner's own claims.
License
Free to use, not open source (yet). The package is published under a use-only license: install
it and build on it, commercially included, but the software may not be modified or
redistributed — see LICENSE for the exact terms. The implementation ships minified with full
TypeScript declarations. The intent is to open the source as the network matures.
