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

@forgesworn/kenspeckle

v0.2.0

Published

Verified relationships across three social distances — kin (family), kith (mutually verified), ken (one-way recognised) — with handshake, bond ceremony, ken trust-store, and local presence discovery.

Readme

@forgesworn/kenspeckle

Verified relationships across three social distances — kin (family), kith (mutually verified), ken (one-way recognised) — with the handshake, bond ceremony, ken trust-store, local presence discovery, and invite flows. Protocol-neutral, pure functions, no storage, no graph traversal.

npm licence TypeScript ESM only

The problem

An app wants to hold a user's relationships — family, people they've met and verified, public keys they recognise — without becoming a social network: no feed, no follower count, no central social graph, no shadow profiles. It also wants two ceremonies most contact systems lack: a bond (two people mutually verify each other out-of-band by speaking short rotating words from a shared secret) and a live key-control challenge (a public figure proves an in-game account really holds the key you pinned).

kenspeckle is those primitives as plain data + pure functions. It does not store anything, render anything, or traverse a graph (those are explicit non-goals). It holds relationships, derives ceremony words, pins and challenges recognised keys, and tests a user's own contacts against a server's published presence filter (via the sibling @forgesworn/tessera-kit) — locally, never uploading contacts.

Naming. kenspeckle (Scots: easily recognised, known by sight) was developed as @forgesworn/kindred and renamed before first publish — "Kindred" is now the ForgeSworn app-suite brand. The wire protocol keeps the historical kindred naming, frozen: 'kindred:bond', 'kindred:members:', 'kindred-bond', kinds 30444/30445, and the KINDRED_* / KindredEntry / KindredTier identifiers are unchanged — renaming them would change every deployed bond word and published filter.

Honest scope (read SECURITY.md): persona-scoping is a consumer obligation kenspeckle assists but cannot guarantee (no storage). NIP-05 is a DNS/TLS/HTTP TOFU anchor, not key-continuity. attributeSignature alone is replayable — impersonation resistance needs verifyKeyControl against a fresh nonce. Private annotations are never serialised. A forged presence filter is a doxxing primitive — verify it against a pinned server key.

Install

npm i @forgesworn/kenspeckle

ESM-only, Node ≥ 22. nostr-tools is a peer dependency (so you and kenspeckle share one event type) — install it alongside.

The @forgesworn/tessera-kit sibling dependency

kenspeckle depends on @forgesworn/tessera-kit (^0.2.1, from npm), which provides the signed membership filters that discovery verifies. A scripts/check-publishable-deps.mjs guard runs in prepack and refuses to publish while any dependencies / optionalDependencies / peerDependencies entry is a git/file/http(s) spec, so kenspeckle can only ship against registry versions.

Quick start

kith — handshake → ECDH bond → spoken-word ceremony

Two people exchange a handshake, derive the byte-exact shared secret, then read each other short rotating words. The word I speak differs from the word I expect (directional — the listener can't parrot it back).

import { buildHandshakePayload, parseHandshakePayload } from '@forgesworn/kenspeckle/handshake'
import { deriveBondSecret, bondWords, verifyBondWord } from '@forgesworn/kenspeckle/bond'

// (1) I present my persona + a fresh nonce over QR/NFC/relay; they parse it.
const blob = buildHandshakePayload({ pubkey: myPubHex, nonce: my16ByteHexNonce })
const theirs = parseHandshakePayload(receivedBytes) // validates; displayName is VERBATIM

// (2) Each side derives the SAME ECDH secret. (myPriv × their x-only pubkey, hash x.)
const secret = deriveBondSecret(myPrivHex, theirs.pubkey)

// (3) Read words for an agreed counter. I say `mine`; I expect to hear `theirs`.
const { mine } = bondWords(secret, myPubHex, theirs.pubkey, counter)
speakAloud(mine)

// (4) Verify what THEY spoke (constant-time compare under the hood).
const result = verifyBondWord(secret, myPubHex, theirs.pubkey, counter, whatTheySaid)
if (result.ok) { /* bonded — persist a KithEntry, encrypt sharedSecret at rest */ }

Migrating from signet-app's signet-me? By DEFAULT bondWords uses namespace 'kindred:bond', so its words differ from signet-me's ('signet:me') — a naive migration changes the words. To cross-verify with a peer who hasn't migrated yet, reproduce signet-me's words by passing bondWords(secret, myPub, theirPub, counter, { namespace: 'signet:me' }) and verifyBondWord(…, { namespace: 'signet:me', tolerance: 1 }) (each seat passes its OWN pubkey first). Both peers must either upgrade together or pass this signet-me namespace.

ken — pin a public figure, then prove LIVE control

attributeSignature ("did the pinned key sign this old event?") is replayable. To bind an in-game account to the real key, issue a fresh nonce and verify the claimant signs it.

import { pinKenFromNip05, buildKeyControlChallenge, verifyKeyControl } from '@forgesworn/kenspeckle/ken'

// Pin via NIP-05 — refuses to pin unless the name resolves (TOFU; HTTPS-only fetch).
const entry = await pinKenFromNip05('[email protected]', myGamingPersonaPubHex, fetch)

// Live challenge: the claimant signs an event whose `content` IS the nonce.
const { nonce } = buildKeyControlChallenge()
const signed = await askClaimantToSign(nonce) // their client builds + signs it
const proof = verifyKeyControl(entry, nonce, signed)
if (proof.ok) { /* the account in front of me holds the pinned key, live (no replay) */ }

discovery — "which of my contacts are here?" (locally)

A server publishes a signed, non-enumerable presence filter (@forgesworn/tessera-kit). The client parses it, verifies it against a pinned server key, then tests its own contacts — scoped to one persona.

// `parseFilter` is re-exported from `./discovery` (it delegates to `@forgesworn/tessera-kit`
// internally) so a consumer importing only kenspeckle's discovery subpath never has to reach past
// it into tessera-kit directly.
import { discoverPresent, parseFilter, parseFilterPublication } from '@forgesworn/kenspeckle/discovery'

// Pull the kind-30444 publication for the (namespace, serverId) YOU chose to fetch — as of 0.2.0
// this REQUIRES opts, since @forgesworn/tessera-kit now binds the in-blob signature to a `context`
// string built ONLY from opts (kindred: `kindred:members:<namespace>:<serverId>`), never from the
// event's own d-tag — that is what stops a relay from relabelling a substituted blob. Verifies BOTH
// the Nostr event sig and the in-blob Schnorr sig (bound to that context), that the event's own
// d-tag equals it, and — default true — that the event's signer IS the in-blob signer
// (`requireAuthorIsSigner`). Returns null on any failure.
const pub = parseFilterPublication(rawWireEvent, {
  namespace: 'com.example.game',
  serverId: 'play.example.com',
}) // pass the RAW event — not a spread-mutated one
if (!pub) throw new Error('untrusted publication')

// Provenance gate: the IN-BLOB signer must be a key you pinned out-of-band.
if (pub.signerPubkeyHex !== PINNED_SERVER_PUBKEY) throw new Error('unpinned server')

// Test MY contacts, scoped to one persona (throws on mixed-owner input — anti-correlation; also
// throws if a salt is passed to an OPEN filter, or omitted for a KEYED one — either mismatch
// silently matches nothing otherwise).
const filter = parseFilter(pub.blob)
const present = discoverPresent(filter, myEntries, myGamingPersonaPubHex /*, salt if keyed */)
// `present` is a CANDIDATE list — confirm-on-connect with a ken key-control challenge before acting.

API by subpath

The . entry carries the model + local ops; the protocol ceremonies are separate subpaths (so a consumer that only needs ./bond doesn't pull the discovery / @forgesworn/tessera-kit graph).

. (model + local ops)

| Export | Purpose | |--------|---------| | types KindredTier, KindredEntry (KinEntry/KithEntry/KenEntry), MutualEntry, KenProvenance, KenRotation, PrivateAnnotations, WireEntry, SyncEntry, DistributiveOmit | the relationship model — KenEntry.corroborations? records additional independent channels agreeing (provenance stays the required, singular primary). WireEntry/SyncEntry are built with DistributiveOmit so narrowing on tier still sees tier-specific fields (sharedSecret, provenance, relationship, …) | | EventTemplate, NostrEvent, NostrFilter | re-exported nostr-tools aliases — one canonical event type | | hasSharedSecret(entry) | type predicate — narrows to the kin/kith arms that carry a sharedSecret | | scopeToPersona(entry, personaPubkeyHex) / assertOwnedPersona(personaPubkeyHex, myLeaves) | persona scoping (the latter throws unless owned) | | searchEntries(entries, query) | local search over name / pubkey / annotations (searchable locally, never serialised) | | linkForRecall(entries, ids) / unlink(entries, pubkey) | private "these pubkeys are one human" grouping (mutates in place) | | toWire(e) / serializeEntry(e) | true wire-safe view + byte-stable canonical JSON — strips both annotations and sharedSecret (0.2.0: previously only annotations). Safe to hand to any third party. A kin/kith WireEntry therefore does not round-trip through parseEntry — the secret's absence is intentional, not a bug | | toSyncForm(e) / serializeEntryForSync(e) | KEEPS sharedSecret (strips only annotations) — for the ONE legitimate case that needs the secret to travel: syncing a roster across the user's OWN devices over an already-private/authenticated transport. Never publish this output | | parseEntry(s) | hardened parse; reconstructs serializeEntryForSync's output (requires sharedSecret for kin/kith either way) | | exportEntriesEncrypted(entries, key) / importEntries(blob, key) | XChaCha20-Poly1305 self-backup — includes annotations (the user's own sealed copy, not a graph disclosure). Backups now carry a KSBK version header; older (pre-header) backups are still readable by importEntries | | toGrantView(entry) / buildGrantEnvelope(scope, views, publishedAt, opts?) / parseGrantEnvelope(s) | companion data-rail envelope: a secret-stripped "read & pick" contact view (GrantContactView — structurally omits sharedSecret/annotations/bondAssertion/provenance) a companion app can be granted, scoped by tier/persona (GrantScope) |

./companion-rail

Pure wire ownership shared by Signet producers and companion consumers: buildPairingUri / parsePairingRequest, buildPairingAck / parsePairingAck, applyCompanionSnapshot, the pairing/snapshot types, and the frozen ACK_KIND, SNAPSHOT_KIND, and SNAPSHOT_D_TAG constants. Relay subscriptions, encryption, device keys, clocks, storage, retry policy and UI remain in the consuming app.

Return rail — WireKen, buildReturnEnvelope / parseReturnEnvelope, landReturnedKen, and the RETURN_D_TAG / RETURN_ADDITIONS_CAP / RETURN_CORROBORATIONS_CAP constants. A companion app holds no identity keys, so it can never mint a kith/kin bond — it can only propose a ken. It can, however, carry the provenance it claims (claimedProvenance / claimedCorroborations): the companion is often where the evidence actually is, and scanning someone's code while standing next to them is real in-person provenance. landReturnedKen preserves that claim as a corroboration namespaced companion:<appName>:<locator> (with confirmedAt clamped to now), while the primary provenance stays { source:'manual', locator:'companion:<appName>' } — what Signet can attest itself. A claim is preserved, never promoted: no app but Signet is a source of identity truth.

./handshake

buildHandshakePayload(p) → bytes; parseHandshakePayload(blob) (8192-byte cap, all fields validated, displayName returned verbatim). Type HandshakePayload.

./bond

deriveBondSecret(myPrivHex, theirPubHex) (byte-exact ECDH); bondWords(secret, aPub, bPub, counter, opts?) → { mine, theirs }; verifyBondWord(…, spoken, opts?) → { ok }; buildBondAttestation({ subjectPubHex, summary? }) → kind-31000 EventTemplate (type:'kindred-bond').

Retracting a bond: buildBondRevocation({ subjectPubHex, reason? }) is now the primary way to retract — it emits a kind-31000 ["status","revoked"] event (nostr-attestations createRevocation) at the SAME addressable slot, so it replaces the attestation and verifyBondAttestation rejects it as revoked. Sign it with the key that signed the attestation. retractBondAssertion(assertion, { attesterPubHex, subjectPubHex }) remains as a supplement: a kind-5 NIP-09 deletion carrying e (the attestation event id), a (its addressable coordinate) and k (kind 31000) tags. Const KINDRED_BOND_NAMESPACE. The optional opts ({ namespace? }, plus tolerance? on verifyBondWord) exist for signet-me migration compatibility — { namespace: 'signet:me', tolerance: N } reproduces signet-app's signet-me words so a migrated contact can cross-verify with an un-migrated peer; defaults ('kindred:bond' + tolerance 0) keep the existing behaviour but differ from signet-me's words.

deriveCeremonyCounter(nonceAHex, nonceBHex) → number — the counter for an in-person bond ceremony: both parties derive the SAME counter from their two handshake nonces (SHA-256, domain-tagged CEREMONY_COUNTER_TAG), symmetric in argument order. timeCounter(nowSec, periodSec) → number — Math.floor(nowSec / periodSec), for a later re-verification (no fresh nonces on hand); the result must fit the uint32 counter range, otherwise it throws (it is not clamped). A fixed counter is not recommended — see PROTOCOL.md §2. normalizeSpokenWord(s) → string — NFKC + trim + lowercase; verifyBondWord already applies this to spoken before comparing, so callers don't need to.

import { deriveCeremonyCounter } from '@forgesworn/kenspeckle/bond'

// Both handshake nonces are exchanged already (see the kith example above).
const counter = deriveCeremonyCounter(myNonceHex, theirs.nonce) // same value on both sides
const { mine } = bondWords(secret, myPubHex, theirs.pubkey, counter)

./ken

pinKen(p) / pinKenFromNip05(nip05, ownerPubkeyHex, fetch) (refuse-on-mismatch TOFU); buildKeyControlChallenge(); verifyKeyControl(entry, nonce, signedEvent, opts?) (live, fail-closed; opts.expectedCreatedAt/maxAgeSec/verifierTag are an OPT-IN, backward-compatible partial mitigation for a verifier-relay attack — see the doc comment); attributeSignature(entry, event) (replayable — credit, don't authenticate); resolveKen (propose-not-flip; a NIP-05 resolving to a key already in previousPubkeys is flagged rotation.rollback: true rather than proposed as an ordinary rotation) / acceptKenRotation(entry, opts?) (throws if already accepted, if newPubkey equals the current pin, or — unless opts: { allowRevert: true } — if the rotation is a flagged rollback) / revokeKen / dropKen (consumer deletes).

Both pinKen and addCorroboration(entry, provenance) run the SAME provenance validation, the same MAX_CORROBORATIONS (64) cap, and reject the reserved companion: locator prefix — a builder can no longer mint an entry the parser would then refuse.

addCorroboration(entry, provenance) records another independent channel agreeing this key is this person — pure, appends in observation order, never touches the primary provenance, and deliberately does not de-duplicate (a re-check a year later is new recency evidence). summarizeKenProvenance(entry) reports { confirmations, claimed, distinctSources, distinctLocators, sources, mostRecentAt, oldestAt }.

NIP-05 handling. Every entry point that can fetch a nip05 (pinKen, pinKenFromNip05, resolveKen) validates a STRICT local@domain shape — no port, no userinfo, no path/query/fragment, and a last label that is alphabetic or xn--, so no IP literal in any form (127.1, 0x7f.1 included). The parser keeps a stored nip05 that 0.1.x accepted; resolveKen treats one that fails the strict check as unresolvable and never fetches it. The same check is exported as validateNip05(value) from ./ken (returns the value, throws on a bad one), so an app can pre-validate input and show its own message. The fetch is hardened: redirect:'error' (NIP-05 requires ignoring redirects), a bounded timeout, a size-capped body read before JSON.parse, and both halves lowercased before querying/looking up (NIP-05 names are case-insensitive).

claimed is the one to render alongside any total. A ken landed from a companion app can show six confirmations across six distinct sources with nothing verified first-hand — every record is a relayed claim. claimed counts the records in the reserved companion: locator namespace, so confirmations - claimed is what was actually confirmed first-hand. Show a total without it and you present relayed claims as verification.

It returns facts, not a trust score — on purpose. A scalar "strength" invites false precision and invites being used as an authorization input (if (strength > 0.7) allow), which is a security decision this primitive cannot underwrite; and weighting channels against each other (is one in-person worth two nip05?) is consumer policy, not a property of the data. It is also honest about its limits: distinctSources can understate independence (two different websites are both web) and distinctLocators can overstate it (one operator can serve two locators; DNS and the site behind it often share one point of compromise). Signals, not guarantees.

./discovery

discoverPresent(filter, entries, ownerPubkey, saltHex?) (throws on mixed persona; also throws if saltHex is given for an OPEN filter or omitted for a KEYED one, or if saltHex is the empty string for a KEYED one (@forgesworn/tessera-kit 0.2.0 rejects an empty salt outright) — any of these would otherwise silently match nothing, a false "no friends here"); disclosureFor(filter: MembershipFilter) → DiscoveryDisclosure (pass the filter parseFilter returned); filterSignatureContext(namespace, serverId) → the kindred convention's filter-signature context string (@forgesworn/tessera-kit 0.2.0), identical to the d-tag value kindred:members:<namespace>:<serverId> — build it from the (namespace, serverId) you chose to fetch, never from a received event's own d-tag; buildFilterPublication(p) — p.blob must already be signed (signFilterBlob) for filterSignatureContext(p.namespace, p.serverId); this function verifies that itself and throws a clear error if it doesn't verify, so a blob signed for the wrong deployment is caught before publishing / parseFilterPublication(event, opts) — opts is now REQUIRED: { namespace, serverId, minEpoch?, requireAuthorIsSigner? } (BREAKING as of 0.2.0; previously optional, with namespace/serverId read off the event's own d-tag). Verifies the Nostr event sig, that the event's d-tag equals filterSignatureContext(opts.namespace, opts.serverId), the in-blob Schnorr sig bound to that same context (@forgesworn/tessera-kit 0.2.0 — closes cross-server/namespace filter substitution cryptographically), and — opts.requireAuthorIsSigner, default true — that the event's signer IS the in-blob signer; pass { requireAuthorIsSigner: false } to opt out for a different trust model. Returns null, never throws — including when opts is missing or opts.namespace/opts.serverId is invalid; parseFilterPublicationResult(event, opts) → { ok: true, value } | { ok: false, reason: FilterPublicationRejection } — the same checks as parseFilterPublication, but naming WHICH one failed instead of collapsing to null (parseFilterPublication is now a thin wrapper over this); aggregatorQuery(namespace); buildOptOutRequest(p, memberPrivHex). Re-exports parseFilter from @forgesworn/tessera-kit. Consts KINDRED_FILTER_KIND = 30444, KINDRED_OPTOUT_KIND = 30445.

./invite

buildJoinInvite(p, inviterPrivHex) → JoinInvite object; parseJoinInvite(blob, now?) (verifies sig + expiry); verifyBondAttestation(event, now?) → { ok, reason?, attesterPubHex?, subjectPubHex? } (single-attestation only — no counting/graph; fails with a reason for a revoked, expired, not-yet-active, or self-attestation). Type JoinInvite.

Invites are v2 only. Every JoinInvite carries v: 2. The signed digest is sha256(JSON.stringify(['kenspeckle-invite', 2, namespace, serverId, inviterPubkey, nonce, expiresAt ?? null])). nonce must be at least 16 bytes of hex; expiresAt, when present, must be a non-negative safe integer; and a lone UTF-16 surrogate anywhere in the invite's string fields is rejected (it would serialise to different bytes depending on the JSON encoder / platform, breaking the byte-exact digest).

Exact byte layouts (the ECDH construction + frozen vector, invite canonical bytes, kind/tag shapes, build/parse asymmetry) are in PROTOCOL.md.

Security

kenspeckle makes narrow, honest claims — read SECURITY.md. The short version:

  • Persona-scoping is a consumer obligation kenspeckle assists (discoverPresent enforces single-owner input) but cannot guarantee (no storage).
  • Private annotations are never serialised (type-level + runtime); they ARE in the user's own encrypted backup (allowed — not a graph disclosure).
  • kith/kin shared secrets are never published — toWire/serializeEntry strip sharedSecret structurally (0.2.0; previously only annotations was excluded). Cross-device sync of your OWN devices, which genuinely needs the secret to travel, uses the separately-named toSyncForm/serializeEntryForSync instead — never publish that output.
  • NIP-05 is TOFU, not key-continuity — a key-change is propose-only, never auto-accept; require user confirmation. A rollback to a key already in previousPubkeys (a compromised/reverted domain re-serving an old key) is flagged distinctly and acceptKenRotation refuses it without an explicit { allowRevert: true }. acceptKenRotation also refuses an already-accepted rotation, so it can never double-append the current key into its own history.
  • attributeSignature is replayable — use verifyKeyControl (fresh nonce) for impersonation resistance.
  • Verify filter publications against a pinned key — check the Nostr sig AND the in-blob Schnorr sig; pass the raw wire event (a spread-mutated one carries a stale Symbol(verified) false-green). Since 0.2.0, parseFilterPublication/ parseFilterPublicationResult require opts: { namespace, serverId } — the in-blob signature is now bound to a context string (@forgesworn/tessera-kit 0.2.0) built only from opts, never from the event's own d-tag, which is what stops a relay/MITM from relabelling a substituted blob (see @forgesworn/tessera-kit PROTOCOL.md §4.3). The event's own d-tag is separately checked to equal that same context ('address-mismatch' on a mismatch). By default (requireAuthorIsSigner) the event's signer must also equal the in-blob signer, defence-in-depth alongside — not instead of — context binding.
  • Discovery is opt-in + exit-able, but you cannot cryptographically verify removal from a pool you left. Real member privacy = per-context personas.
  • tier:'kith' is a shape, not proof a bond happened. parseEntry/ importEntries accept any syntactically-valid 64-hex sharedSecret — kenspeckle cannot itself confirm a real deriveBondSecret ceremony occurred. Authenticate/encrypt whatever channel feeds parseEntry/importEntries; an unauthenticated sync source could otherwise escalate a one-way ken into a mutually-trusted-looking kith.

Toolkit

kenspeckle is the relationships brick of the Forgesworn / Signet ecosystem. It composes:

  • @forgesworn/tessera-kit — the non-enumerable membership-presence filter kenspeckle/discovery is a thin layer over.
  • spoken-token — the directional verification words the bond ceremony speaks.
  • nostr-attestations — the co-signed bond assertions (kind-31000).
  • nsec-tree — hierarchical persona key derivation (the ownerPubkey scoping; kenspeckle needs only the type relationship).

Consumed by signet-app (the user-facing identity app): it maps its Contact record onto KinEntry/KithEntry/ken, persists KindredEntry in IndexedDB, and keeps tier/badge enrichment app-side. The byte-exact ECDH (PROTOCOL.md §1) is what makes that migration lossless.

Licence

MIT.