diff options
| author | Adam Soltys <[email protected]> | 2026-10-02 21:13:27 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-10-02 21:13:27 -0700 |
| commit | 0d698629a75f21046d6e544b34c42e0b2803b5a9 (patch) | |
| tree | 1f211e1a71be848e8c1dc09d76d6ce148b651214 /src/marmot-store.js | |
| parent | 544c61e8c27652c71501277b10875eeead0a402c (diff) | |
| download | coinosv3-0d698629a75f21046d6e544b34c42e0b2803b5a9.tar.gz coinosv3-0d698629a75f21046d6e544b34c42e0b2803b5a9.zip | |
White Noise (Marmot) chats: MLS group messaging in native JS
The Chat tab now speaks the protocol the White Noise app speaks, beside
Concord communities and NIP-17 DMs, without anyone having to pick one:
- A two-person group with no name is a direct message. It lands in the
same thread as that person's NIP-17 messages, and a reply goes back the
way their last message came. A first message goes by White Noise only
to someone whose KeyPackage another app published (a coinos wallet
reads NIP-17 on all its devices; an MLS group reaches one).
- Groups get rows of their own in the Messages list, a New group button,
an invitation card, members, add/remove/rename for admins, leave.
- Every wallet publishes a KeyPackage (kind 30443), so it can be invited.
src/mls.js is RFC 9420 for ciphersuite 0x0001 on the noble libs (no
WebAssembly), with the extensions-draft pieces Marmot needs
(app_data_dictionary, app_data_update, app_ephemeral, self_remove).
src/marmot.js is the Marmot layer: components, the account identity
proof, the 30443/444/445 event shapes and the group engine
(publish-before-apply, retained epochs, same-epoch tie-break).
State is per device, in IndexedDB (src/marmot-store.js). The whole stack
is one lazy chunk (72 KB).
Tested against the RFC 9420 vectors (tools/mls-vectors-test.js) and
against the Rust reference, MDK's wn CLI, at the protocol level
(tools/marmot-interop-test.js) and through the real UI
(tools/marmot-chat-test.js).
Not yet: attachments in these chats, push for group messages, the
app-message witness boost in fork selection.
Co-Authored-By: Claude Fable 5.1 <[email protected]>
Diffstat (limited to 'src/marmot-store.js')
| -rw-r--r-- | src/marmot-store.js | 74 |
1 files changed, 74 insertions, 0 deletions
diff --git a/src/marmot-store.js b/src/marmot-store.js new file mode 100644 index 0000000..ac9b044 --- /dev/null +++ b/src/marmot-store.js @@ -0,0 +1,74 @@ +// Where White Noise (Marmot) state lives: IndexedDB, one record per group. +// +// MLS state is the opposite of everything else this wallet keeps: it cannot +// be refetched or rederived (a lost ratchet is a lost group), it holds +// Uint8Arrays by the dozen (structured clone stores them as-is, JSON would +// triple them), and it is DEVICE state — two devices replaying one leaf's +// ratchet would reuse keys. So it stays out of the localStorage feature +// state and out of the cross-device sync, in a store of its own. +// +// Records are scoped by `scope` (wallet cache key + nostr identity), so two +// wallets or two identities on one browser never read each other's groups. + +const DB = 'coinos-marmot'; +const STORE = 'kv'; + +let dbP = null; +function db() { + if (!dbP) + dbP = new Promise((resolve, reject) => { + if (typeof indexedDB === 'undefined') return reject(new Error('IndexedDB unavailable')); + const req = indexedDB.open(DB, 1); + req.onupgradeneeded = () => { + if (!req.result.objectStoreNames.contains(STORE)) req.result.createObjectStore(STORE); + }; + req.onsuccess = () => { + // another tab upgrading or deleting the database must not be blocked + req.result.onversionchange = () => { req.result.close(); dbP = null; }; + resolve(req.result); + }; + req.onerror = () => { dbP = null; reject(req.error); }; + }); + return dbP; +} + +async function tx(mode, fn) { + const d = await db(); + return new Promise((resolve, reject) => { + const t = d.transaction(STORE, mode); + let out; + try { out = fn(t.objectStore(STORE)); } catch (e) { reject(e); return; } + t.oncomplete = () => resolve(out && 'result' in out ? out.result : out); + t.onerror = () => reject(t.error); + t.onabort = () => reject(t.error); + }); +} + +export function marmotStore(scope) { + const pre = scope + '|'; + const range = () => IDBKeyRange.bound(pre, pre + ''); + return { + // every record of this scope: Map(name -> value) + async all() { + const out = new Map(); + await tx('readonly', (s) => { + const cur = s.openCursor(range()); + cur.onsuccess = () => { + const c = cur.result; + if (!c) return; + out.set(String(c.key).slice(pre.length), c.value); + c.continue(); + }; + }); + return out; + }, + put: (name, value) => tx('readwrite', (s) => { s.put(value, pre + name); }), + del: (name) => tx('readwrite', (s) => { s.delete(pre + name); }), + wipe: () => tx('readwrite', (s) => { s.delete(range()); }), + }; +} + +// Drop every scope under a wallet (the account-wipe hook). +export function wipeMarmot(walletKey) { + return tx('readwrite', (s) => { s.delete(IDBKeyRange.bound(walletKey, walletKey + '')); }).catch(() => {}); +} |
