@internet-privacy/marmot-ts
v0.9.0
Published
Marmot protocol implementation in TypeScript
Readme
marmot-ts
TypeScript implementation of the Marmot protocol — end-to-end encrypted group messaging on Nostr using MLS (Messaging Layer Security).
[!WARNING] This library is in Alpha and under heavy development. The API is subject to breaking changes without notice. It relies on ts-mls for MLS cryptographic guarantees. Do not use in production yet.
Features
- 🔐 End-to-end encrypted group messaging using MLS (RFC 9420)
- 🌐 Decentralized — groups operate across Nostr relays
- 🔑 Key package lifecycle — publishing, rotation, deletion
- 📦 Storage-agnostic — bring any
GenericKeyValueStorebackend (LocalForage, IndexedDB, in-memory, …) - 🔌 Network-agnostic — works with any Nostr client library
- 📱 Cross-platform — browsers, Node.js (v20+), Bun (v1.1+), and Deno (v2+)
Marmot Protocol Compliance
marmot-ts tracks the Marmot v2 protocol and is wire-compatible with the darkmatter reference implementation — including the v2 app-component group model, MLS PublicMessage-framed handshakes, and the account identity proof app component (0x8009, marmot.member.account-identity-proof.v2).
It currently supports the following Marmot Improvement Proposals (MIPs):
| MIP | Description | Status | | -------------------------------------------------------------------------- | --------------------------------------- | -------------- | | MIP-00 | Introduction and Basic Operations | ✅ Supported | | MIP-01 | Network Transport & Relay Communication | ✅ Supported | | MIP-02 | Identities and Keys | ✅ Supported | | MIP-03 | Group State & Memberships | ✅ Supported | | MIP-04 | Encrypted Media | 🚧 In progress |
Installation
npm install @internet-privacy/marmot-ts
# or
pnpm add @internet-privacy/marmot-tsConcepts
A MarmotClient needs four things to operate:
- A signer (
EventSigner, fromapplesauce-core) — signs Nostr events on behalf of the user, including the0x8009account identity proof carried on every key package's LeafNode. - A network interface (
NostrNetworkInterface) — publishes, requests, and subscribes to events on relays. - A group state store — persists serialized MLS group state (
GenericKeyValueStore<SerializedClientState>). - A key package store — persists local key package material (
GenericKeyValueStore<StoredKeyPackage>).
The stores share a single interface: GenericKeyValueStore<T>.
You can optionally supply:
inviteStore— persists received invites; defaults to an in-memory store.historyFactory— wires a per-group message history backend (seeGroupRumorHistory).clientId— a stabled-tag slot: 32 random bytes encoded as 64 lowercase hex characters, persisted and reused for your kind 30443 key packages.
The client exposes three managers — client.groups, client.keyPackages, and client.invites — plus client.network and the joinGroupFromWelcome entry point.
Storage
interface GenericKeyValueStore<T> {
getItem(key: string): Promise<T | null>;
setItem(key: string, value: T): Promise<T>;
removeItem(key: string): Promise<void>;
clear(): Promise<void>;
keys(): Promise<string[]>;
}Any backend that matches this shape works. LocalForage instances satisfy it directly:
import localforage from "localforage";
const groupStateStore = localforage.createInstance({ name: "marmot-groups" });
const keyPackageStore = localforage.createInstance({ name: "marmot-keys" });For tests or short-lived processes, the library ships an in-memory implementation under the ./extra subpath:
import { InMemoryKeyValueStore } from "@internet-privacy/marmot-ts/extra";
const groupStateStore = new InMemoryKeyValueStore();
const keyPackageStore = new InMemoryKeyValueStore();Quick Start
Create the client
import { MarmotClient } from "@internet-privacy/marmot-ts";
import { bytesToHex, randomBytes } from "@noble/hashes/utils.js";
// Generate once per installation, persist it, and load the same slot on restart.
const clientId = bytesToHex(randomBytes(32));
const client = new MarmotClient({
signer, // your EventSigner (e.g. from applesauce-core)
network, // your NostrNetworkInterface implementation
groupStateStore, // GenericKeyValueStore<SerializedClientState>
keyPackageStore, // GenericKeyValueStore<StoredKeyPackage>
clientId, // persisted random slot; arbitrary device labels are rejected
});Publish a key package
Other users invite you by referencing a key package you've published to relays.
await client.keyPackages.create({
relays: ["wss://relay.example.com"],
});Create a group
const group = await client.groups.create("My Secret Group", {
description: "A private discussion",
relays: ["wss://relay.example.com"],
adminPubkeys: [await client.signer.getPublicKey()],
});Send a message
Messages are app-defined Nostr rumors. Build a chat rumor, wrap it in an application-message intent, and submit it through the group's MLS session:
import {
createApplicationMessageIntent,
createChatRumor,
} from "@internet-privacy/marmot-ts";
const rumor = createChatRumor({
pubkey: await client.signer.getPublicKey(),
content: "Hello, world!",
});
await client.groups.send(group.id, createApplicationMessageIntent(rumor));
createChatRumorproduces a kind 9 rumor — a chat convention, not part of the protocol. You can serialize any unsigned rumor as an application message.
Invite a member
Look up their key package event on a relay, then invite by event. This adds them in a single commit and delivers an encrypted Welcome:
const [keyPackageEvent] = await client.network.request(
["wss://relay.example.com"],
[{ kinds: [30443], authors: [memberPubkey], limit: 1 }],
);
if (keyPackageEvent) {
await client.groups.invite(group.id, keyPackageEvent);
}Join a group from an invite
Invites arrive as kind 1059 gift wraps. The client.invites manager ingests,
decrypts, and stores them for you:
// Feed gift-wrap events in as they arrive from relays
await client.invites.ingestEvent(giftWrapEvent);
// Decrypt pending gift wraps into kind 444 Welcome rumors
await client.invites.decryptGiftWraps();
// getUnread() returns the decrypted kind 444 Welcome rumors directly
const [welcomeRumor] = await client.invites.getUnread();
if (welcomeRumor) {
const { group } = await client.joinGroupFromWelcome({ welcomeRumor });
await client.invites.markAsRead(welcomeRumor.id);
}If you already hold a decrypted kind 444 Welcome rumor, you can join directly:
const { group } = await client.joinGroupFromWelcome({ welcomeRumor });Receive messages
Decrypted application messages surface through the group's applicationMessage
event as serialized rumors — deserialize them with deserializeApplicationData:
import { deserializeApplicationData } from "@internet-privacy/marmot-ts";
group.on("applicationMessage", (data) => {
const rumor = deserializeApplicationData(data);
console.log(`${rumor.pubkey}: ${rumor.content}`);
});To deliver inbound traffic, subscribe to the group's relays for kind 445 events
and feed them to group.ingest. The async generator drives MLS processing and
yields a disposition per event (processed, unreadable, deferred, …);
readable application messages are emitted via the event above:
import { bytesToHex } from "@noble/hashes/utils.js";
const subscription = client.network.subscription(group.relays, [
{ kinds: [445], "#h": [bytesToHex(group.groupData.nostrGroupId)] },
]);
subscription.subscribe({
next: async (event) => {
for await (const result of group.ingest([event])) {
if (result.kind === "unreadable")
console.warn("dropped an unreadable event");
}
},
});Package entrypoints
The exports map exposes the library as focused subpaths:
| Import path | Contents |
| ------------------------------------ | ----------------------------------------------------------------------------------------------- |
| @internet-privacy/marmot-ts | The common surface — re-exports ./client, ./core, and ./utils |
| @internet-privacy/marmot-ts/client | MarmotClient, MarmotGroup, managers, intents, history, network |
| @internet-privacy/marmot-ts/core | Protocol/crypto/state primitives with no app I/O |
| @internet-privacy/marmot-ts/engine | MarmotGroupEngine and the convergence/ingest state machine |
| @internet-privacy/marmot-ts/extra | Optional stores — InMemoryKeyValueStore, encrypted store, history backend |
| @internet-privacy/marmot-ts/utils | Encoding, key-value, Nostr, relay-url, and timestamp helpers |
| @internet-privacy/marmot-ts/mls | The forked ts-mls MLS engine, bundled inside the package |
Import MLS primitives (ciphersuites, ClientState, processMessage, and so on) from
@internet-privacy/marmot-ts/mls, not from a separately installed ts-mls. npm ts-mls
is the upstream build; it lacks the fork's additions (app_data_dictionary, self-remove,
senderLeafIndex, the GroupContext encoder), and its classes and types are distinct
from the copy marmot-ts uses. The X448, ChaCha20-Poly1305, ML-KEM, X-Wing and ML-DSA
backends are optional peer dependencies, needed only for those ciphersuites.
Documentation
Full documentation is in docs/ and served via VitePress. Run pnpm docs:dev to browse locally.
- Getting Started — first-run walkthrough
- Architecture — component overview and Nostr/MLS mapping
- Client Module —
MarmotClient,MarmotGroup, storage, network, UI integration - Core Module — protocol, credentials, key packages, groups, messages, welcome
pnpm docs:build also generates the API reference beside the topic guides. See
message helpers,
founding retries,
and storage upgrades for the
verified narrowing, delivery, and checkpoint boundaries.
Development
Use Node.js 20+ and pnpm 10. Test-source typing is an explicit command, separate from runtime tests and watch mode; the Node 20/22/24 CI matrix enforces it.
pnpm install --frozen-lockfile # Install declared dependencies
pnpm build # Compile and vendor the ts-mls fork
pnpm typecheck:tests # Strictly check all src tests and helpers
pnpm vitest run # Run runtime tests once
pnpm test # Run runtime tests in watch mode
pnpm format # Format code with Prettier
pnpm docs:dev # Serve topic guides locally
pnpm docs:build # Build guides and generate the API referenceSee Contributing for the complete validation workflow.
