Circle primitives for private Nostr groups that nobody operates: a room key per epoch, sealed to each device that stays; link invitations as capabilities; member removal by rekey; device credentials so a person approves a device once and it signs from then on.
Fold-kit is extracted from KithMoot so that other ForgeSworn clients can share one circle model. KithMoot's wire format does not change: every derivation label and event shape moved byte-identical from the pinned source commit and is pinned by known-answer vectors (see EXTRACTION.md). Framework-free: owns no storage, UI, relay defaults or identity keys, and no relay I/O of its own - callers inject a transport.
Status: T1 extraction complete (see EXTRACTION.md). Not yet published to npm under this API; pin an immutable Git commit until it is (see Install). KithMoot itself has not cut over to this kit yet - it still carries its own copy of these modules.
npm install @forgesworn/fold-kitNot yet published to npm. Until it is, pin an immutable Git commit:
{
"dependencies": {
"@forgesworn/fold-kit": "github:forgesworn/fold-kit#<commit>"
}
}{
"peerDependencies": {
"nostr-tools": ">=2.24.2 <3",
"@noble/hashes": "^1.8.0",
"@noble/curves": "^2.0.1"
}
}Peers, not dependencies, so a consumer such as KithMoot keeps a single copy
of nostr-tools (guarded by its own overrides and version-guard test) and
of the noble libraries, at the same versions KithMoot already pins.
@noble/hashes is pinned to its 1.x major only: its 2.x major changed its
hkdf signature to reject a string info argument (this kit's HKDF calls
pass strings) and its exports map no longer resolves the bare subpaths
this kit and KithMoot both import without a .js suffix, so 2.x cannot
satisfy this kit's imports as written.
ESM only ("type": "module"). Node.js >=22.13.
Two entry points: @forgesworn/fold-kit (everything) and
@forgesworn/fold-kit/lane (just the lane helpers, for a consumer that only
wants to classify a relay as public/sheltered/direct without pulling in
the rest).
hexEquals,normaliseHex- constant-shape hex comparison and lower-casingverifyEventUncached,boundedEventVerifier- Nostr event signature checkslocalIdentity,ParticipantIdentity,UnsignedEvent- the signer seam every codec below is built on
KINDS- the circle layer's kind numbers (CREDENTIAL,CHAT,INVITATION_REQUEST,INVITATION_GRANT,INVITATION_RETIREMENT,GROUP_INVITATION,ROOM_REKEY,EPOCH_REQUEST,EPOCH_GRANT) - a subset of KithMoot's full registry.CHAT(1460) is included because a downstream app's own board events are designed to share KithMoot's chat kind, so a relay cannot tell a board from a chat (see docs/extraction-plan-excerpt.md).DeviceCredential,AccessTier,AgentRule,RoomPolicy,KindredProof- the link-layer wire typesRelayTransport- the transport seam (publish/subscribe/close) every codec below that talks to a relay takes as a dependency. This kit declares its own interface rather than importing KithMoot's relay pool, which has not moved here (see EXTRACTION.md).
generateRoomSecret,deriveRoom,encodeJoinUrl,decodeJoinUrl,parseRoomPolicy- the legacy v1 join URL and room id/key derivationcreateDeviceCredential,verifyDeviceCredential,PERSON_CREDENTIAL_MAX_SECONDS- room-scope and person-scope device credentials.createDeviceCredentialthrowsRestampedCredentialExpiryErrorfor a person-scope credential a restamping signer would make unverifiable everywhere (see CHANGELOG.md) - catch it and retry with a shorterexpiresAtmarginissueKindredProof,evaluateAccess- kindred-tier admissionsanitiseDisplayName,MAX_DISPLAY_NAME_LENGTH- defused display namessafeRelayUrls,safeIceUrls,assertNetworkHintBounds, and the relatedMAX_*bounds - link envelope network hints
parseRoomLink,encodeRoomLink,RoomLink- the v1/v2/v3 link envelopecreateRoomInvitation,roomInvitation,deriveInvitationId,encodeInvitationRequest,decodeInvitationRequest,verifyInvitationDelegation,encodeInvitationGrant,decodeRoomAdmissionGrant,encodeInvitationRetirement,decodeInvitationRetirementNotice,hostRoomInvitation,requestRoomAdmission- the v2 live rendezvous invitation, its delegation chain and retirementencodePersistentInvitation,decodePersistentInvitation,requestPersistentRoomAdmission- the v3 stored group invitation (1463). PassendsAt(unix seconds, afternowand at most 30 days on) for a conference room: the encrypted body carriesendsand the event a NIP-40expirationtag, so relays drop it when the room ends; the decoder returnsendsAtand refuses a tag that disagrees with the body- Room relays: pass
relays(one to eight distinct safe relay URLs in canonicalnormalizeURLform, e.g.wss://relay.example.com/) and the encrypted body carries them afterends. Every member's pool includes them, so members never land on disjoint relays. The decoder returnsrelaysand refuses the whole envelope on a malformed list;requestPersistentRoomAdmissionkeeps the relays of the newest signed copy that names any.isInvitationRelays,requireInvitationRelaysandMAX_INVITATION_RELAYSare the rule withExpiration,isRoomEnds,requireRoomEnds,MAX_ROOM_ENDS_SECONDS- the conference-room expiration rule: add the end as anexpirationtag, keep an earlier one, lower a later one.encodeInvitationRetirementtakesendsAt, and the epoch encoders,hostRoomEpochandrequestRoomEpochtakeexpiresAt, to tag what they sign the same way
deriveEpoch,generateEpochSecret- per-epoch id/key derivationencodeRekeyEvent,peekRekeyEvent,decodeRekeyEvent- the durable rekey notice (1462), sealed per remaining deviceencodeEpochRequest,decodeEpochRequest,encodeEpochGrant,decodeEpochGrant,epochRequestAdmission,deriveEpochRequestKey- catch-up for a device that missed a rekeyhostRoomEpoch,requestRoomEpoch,EpochRefusedError- the live request/grant desk- Member epoch catch-up (
docs/member-epoch-catch-up.md): any current member can bring an admitted, non-removed device up to date when the authority is offline, and the device checks the answer against the authority's own signatures rather than trusting the member.encodeRekeyEvent({ commit: true })writes the epoch commitment (epochCommitment) into the rekey body;hostMemberEpochDeskanswers member requests (kinds 20471/20472,MEMBER_EPOCH_KINDS);requestRoomEpoch({ members: memberEpochSource(...) })asks members as well as the authority, andrequestMemberEpochasks members alone. Codecs:encodeMemberEpochRequest,decodeMemberEpochRequest,encodeMemberEpochGrant,decodeMemberEpochGrant,readRekeyEvidence,deriveMemberEpochRequestKey. Vectors:vectors/member-epoch-vectors.json - The known-members gate (#207,
docs/member-epoch-catch-up.md): once a room has removed anybody, both desks grant only to participants the room knows (known), and send anybody else to approval (onUnknown; the authority answersrefused: 'unknown', whichrequestRoomEpochwaits through).encodeRekeyEvent({ members })andhostRoomEpoch({ members })carry the authority's member list (readMemberListreads one) - Seal keys (
docs/seal-key.md): a credential may name a seal key (createDeviceCredential({ seal }),generateSealKey), and rekeys and both grants are sealed to the newest one the sender holds for a device instead of its device key, so a copied device heals once its credential lapses and the room rekeys. Recipients as{ device, credential };sealSkson the readers;credentialForon both desks;credentialSeal,sealTarget,sealTo,openSealed,newerCredential,sealCredential. Vectors:vectors/seal-vectors.json - Scheduled rekeys and the history window (
docs/scheduled-rekey.md):encodeRekeyEvent({ scheduled: true })marks a turn of the key that removes nobody, read back asRekeyNotice.scheduledandRekeyEvidence.scheduled;HISTORY_WINDOW_SECONDS,MAX_HISTORY_EPOCHSandepochsInWindoware the rule for which left epochs a member reads;encodeEpochGrant({ passed })andhostRoomEpoch({ past })hand those epochs (LeftEpoch) to every device the authority grants, read back asEpochGrant.passed. Vectors:vectors/schedule-vectors.json canonicalAdmins,signAdmins,verifyAdmins- the authority's signed admin listcanonicalChannels,signChannels,verifyChannels,CHANNEL_NAME,RESERVED_CHANNELS- the authority's signed channel list
deriveScoped- derive an app-defined{ id, key }pair from an epoch key under an app's own labelled namespace (e.g.myapp/v1/board/<id>/update), so an app can ride the same epoch as the roster and the chat - and rotate on the same rekey - without ever deriving a key that could be mistaken for a KithMoot channel. Refuses this kit's own protocol namespace and any label outsideSCOPED_LABEL_PATTERN.createSubKeyCertificate,verifySubKeyCertificate,SUB_KEY_CERTIFICATE_SCOPE- a small, locally-signed statement that a credentialled device minted a particular app-derived key (aderiveScopedoutput, typically) for a particular scoped id. Rides inside a signed message's ciphertext alongside the device credential it depends on; never published on its own, and never accepted byverifyDeviceCredential(itsscope: "sub"is a value that function already refuses). Verification is strict and canonical: exactly four two-element tags in a fixed order, emptycontent, anexpirationin canonical decimal form compared as the exact tag string (not numerically) against the device credential passed in -verifySubKeyCertificatereads that credential's owndeviceandexpirationtags itself, rather than trusting a caller to have copied them out correctly.
deriveChannel,CHANNEL_ID_INFO,CHANNEL_KEY_INFO,MAX_CHANNEL_NAME_LENGTH- a named channel's id/key, derived from the room key.ChatLogand the chat event codecs stay in KithMoot; only the derivation moved.
LANES,LANE_MEANING,LANE_LABEL,LANE_GLYPH,isLane,laneOfRelayUrl,laneOfRelays,weakestLane,isDowngrade- classifies a relay aspublic,shelteredordirect
covey-kit: circle state derived from a root, latest-wins configuration, per-recipient gift wraps.roost-kit: transport for private circles (gift wrap, relay fan-out, offline outbox).fold-kit: symmetric epoch keys with a pinned authority, sealed rekeys and link invitations. Suited to high-rate encrypted streams such as chat and collaborative documents, where a wrap per recipient per message is too costly.
Nothing is shared in code between the three today: the key models are
incompatible (a static epoch secret with sealed rekeys here, versus a
deterministic reseed from a root in covey-kit), the trust models differ
(pinned authority here versus caller-enforced roles in covey-kit), and
covey-kit/roost-kit pin a different nostr-tools version than this kit and
KithMoot share.
npm install
npm run build # compile TypeScript into dist/
npm test # run the Vitest suite, including both vector files
npm run typecheck # type-check src/ and test/ (matching KithMoot, vectors/ is not tsc-checked)
npm run vectors # run only the vector-verification suites
npm run diff-source # compare moved modules against the pinned source commit (needs FOLD_KIT_SOURCE_DIR)
npm run generate-fold # regenerate vectors/fold-vectors.json (needs a prior build)
npm run bundle-check # esbuild browser bundle check (needs a prior build)
npm run check # typecheck + test + diff-sourceThere is no separate lint script.
MIT