npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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

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/sdk

Identity 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 id

Keep 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 createConversation calls 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.